mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Add audit system, invite links, and usage analytics (#4749)
# Description of Changes New Features Audit System: Complete audit logging with dashboard, event tracking, and export capabilities Invite Links: Secure invite system with email notifications and expiration Usage Analytics: Endpoint usage statistics and visualization License Management: User counting with grandfathering and license enforcement ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
f5c67a3239
commit
ac3e10eb99
@@ -368,6 +368,10 @@ public class ApplicationProperties {
|
||||
private TempFileManagement tempFileManagement = new TempFileManagement();
|
||||
private DatabaseBackup databaseBackup = new DatabaseBackup();
|
||||
private List<String> 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NodeTuple> 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<Node> 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<NodeTuple> 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<Node> 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.
|
||||
*
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-5
@@ -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
|
||||
}
|
||||
|
||||
+434
@@ -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<AuditEventsResponse> 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<PersistentAuditEvent> 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<AuditEventDto> 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<AuditChartsData> 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<PersistentAuditEvent> events = auditRepository.findByTimestampAfter(startDate);
|
||||
|
||||
// Count events by type
|
||||
Map<String, Long> eventsByType =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getType, Collectors.counting()));
|
||||
|
||||
// Count events by principal (user)
|
||||
Map<String, Long> eventsByUser =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getPrincipal, Collectors.counting()));
|
||||
|
||||
// Count events by day
|
||||
Map<String, Long> 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<String, Long> 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<List<String>> getEventTypes() {
|
||||
// Get distinct event types from the database
|
||||
List<String> dbTypes = auditRepository.findDistinctEventTypes();
|
||||
|
||||
// Include standard enum types in case they're not in the database yet
|
||||
List<String> enumTypes =
|
||||
Arrays.stream(AuditEventType.values())
|
||||
.map(AuditEventType::name)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Combine both sources, remove duplicates, and sort
|
||||
Set<String> combinedTypes = new HashSet<>();
|
||||
combinedTypes.addAll(dbTypes);
|
||||
combinedTypes.addAll(enumTypes);
|
||||
|
||||
List<String> 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<List<String>> getUsers() {
|
||||
// Use the countByPrincipal query to get unique principals
|
||||
List<Object[]> principalCounts = auditRepository.countByPrincipal();
|
||||
|
||||
List<String> 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<byte[]> 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<PersistentAuditEvent> 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<String, Object> details = new HashMap<>();
|
||||
if (event.getData() != null && !event.getData().isEmpty()) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> 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<byte[]> exportAsCsv(List<PersistentAuditEvent> 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<byte[]> exportAsJson(List<PersistentAuditEvent> 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<AuditEventDto> 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<String, Object> 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<String> labels;
|
||||
private List<Integer> values;
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -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<Team> teams;
|
||||
private int maxPaidUsers;
|
||||
private int maxAllowedUsers;
|
||||
private long availableSlots;
|
||||
private int grandfatheredUserCount;
|
||||
private int licenseMaxUsers;
|
||||
private boolean premiumEnabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+236
@@ -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<EndpointStatisticsResponse> getEndpointStatistics(
|
||||
@RequestParam(value = "limit", required = false) Integer limit,
|
||||
@RequestParam(value = "dataType", defaultValue = "all") String dataType) {
|
||||
|
||||
// Get all HTTP_REQUEST audit events
|
||||
List<PersistentAuditEvent> httpEvents =
|
||||
auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name());
|
||||
|
||||
// Count visits per endpoint
|
||||
Map<String, Long> 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<EndpointStatistic> 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<String, Object> 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<EndpointStatistic> endpoints;
|
||||
private int totalEndpoints;
|
||||
private int totalVisits;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class EndpointStatistic {
|
||||
private String endpoint;
|
||||
private int visits;
|
||||
private double percentage;
|
||||
}
|
||||
}
|
||||
+65
@@ -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 = "";
|
||||
}
|
||||
+8
@@ -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();
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
-1
@@ -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(
|
||||
|
||||
+37
-4
@@ -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;
|
||||
}
|
||||
|
||||
+488
@@ -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> 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<InviteToken> 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<String, Object> 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<InviteToken> activeInvites =
|
||||
inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now());
|
||||
|
||||
List<Map<String, Object>> inviteList =
|
||||
activeInvites.stream()
|
||||
.map(
|
||||
invite -> {
|
||||
Map<String, Object> 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<InviteToken> 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<InviteToken> 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<InviteToken> 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<String, Object> 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<InviteToken> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-21
@@ -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> 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<User> 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
|
||||
|
||||
+3
@@ -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"
|
||||
};
|
||||
|
||||
|
||||
+62
@@ -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();
|
||||
}
|
||||
}
|
||||
+32
@@ -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<InviteToken, Long> {
|
||||
|
||||
Optional<InviteToken> findByToken(String token);
|
||||
|
||||
Optional<InviteToken> findByEmail(String email);
|
||||
|
||||
List<InviteToken> findByUsedFalseAndExpiresAtAfter(LocalDateTime now);
|
||||
|
||||
List<InviteToken> 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);
|
||||
}
|
||||
+21
@@ -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<UserLicenseSettings, Long> {
|
||||
|
||||
/**
|
||||
* Finds the singleton UserLicenseSettings record.
|
||||
*
|
||||
* @return Optional containing the settings if they exist
|
||||
*/
|
||||
default Optional<UserLicenseSettings> findSettings() {
|
||||
return findById(UserLicenseSettings.SINGLETON_ID);
|
||||
}
|
||||
}
|
||||
+54
@@ -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 =
|
||||
"""
|
||||
<html><body style="margin: 0; padding: 0;">
|
||||
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
|
||||
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
|
||||
<!-- Logo -->
|
||||
<div style="text-align: center; padding: 20px; background-color: #222;">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div style="padding: 30px; color: #333;">
|
||||
<h2 style="color: #222; margin-top: 0;">Welcome to Stirling PDF!</h2>
|
||||
<p>Hi there,</p>
|
||||
<p>You have been invited to join the Stirling PDF workspace. Click the button below to set up your account:</p>
|
||||
<!-- CTA Button -->
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Accept Invitation</a>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
|
||||
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
|
||||
%s
|
||||
</div>
|
||||
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; color: #856404; font-size: 14px;"><strong>⚠️ Important:</strong> This invitation link will expire on %s. Please complete your registration before then.</p>
|
||||
</div>
|
||||
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
|
||||
© 2025 Stirling PDF. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(inviteUrl, inviteUrl, expiresAt);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
+411
@@ -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.
|
||||
*
|
||||
* <p>User limit calculation:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Default limit: 5 users
|
||||
* <li>Grandfathered limit: max(5, existing user count at initialization)
|
||||
* <li>With pro license: grandfathered limit + license maxUsers
|
||||
* <li>Without pro license: grandfathered limit
|
||||
* </ul>
|
||||
*/
|
||||
@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.
|
||||
*
|
||||
* <p>IMPORTANT: Once grandfathering is locked, this value can NEVER be changed. This prevents
|
||||
* manipulation by deleting the settings table.
|
||||
*
|
||||
* <p>Logic:
|
||||
*
|
||||
* <ul>
|
||||
* <li>If grandfatheringLocked is true: Skip initialization (already set permanently)
|
||||
* <li>If users exist in database: Set to max(5, current user count) - this is an existing
|
||||
* installation
|
||||
* <li>If no users exist: Set to 5 (default) - this is a fresh installation
|
||||
* <li>Lock grandfathering immediately after setting
|
||||
* </ul>
|
||||
*/
|
||||
@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<Integer> 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.
|
||||
*
|
||||
* <p>Logic:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Grandfathered limit = max(5, existing user count at initialization)
|
||||
* <li>If premium enabled: total limit = grandfathered limit + license maxUsers
|
||||
* <li>If premium disabled: total limit = grandfathered limit
|
||||
* </ul>
|
||||
*
|
||||
* @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).
|
||||
*
|
||||
* <p>Examples:
|
||||
*
|
||||
* <ul>
|
||||
* <li>If grandfathered = 5: returns 0 (base amount, nothing special)
|
||||
* <li>If grandfathered = 10: returns 5 (5 extra users)
|
||||
* <li>If grandfathered = 15: returns 10 (10 extra users)
|
||||
* </ul>
|
||||
*
|
||||
* @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<Integer> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "[email protected] (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",
|
||||
|
||||
@@ -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 */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||
@@ -191,7 +192,7 @@ export default function Workbench() {
|
||||
</Box>
|
||||
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
analyticsEnabled={config?.enableAnalytics ?? undefined}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon } from '@mantine/core';
|
||||
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import Overview from '@app/components/shared/config/configSections/Overview';
|
||||
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -15,20 +15,44 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState<NavKey>('general');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
const getSectionFromPath = (pathname: string): NavKey | null => {
|
||||
const match = pathname.match(/\/settings\/([^/]+)/);
|
||||
if (match && match[1]) {
|
||||
const section = match[1] as NavKey;
|
||||
return VALID_NAV_KEYS.includes(section as NavKey) ? section : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Sync active state with URL path
|
||||
useEffect(() => {
|
||||
const section = getSectionFromPath(location.pathname);
|
||||
if (opened && section) {
|
||||
setActive(section);
|
||||
} else if (opened && location.pathname.startsWith('/settings') && !section) {
|
||||
// If at /settings without a section, redirect to general
|
||||
navigate('/settings/general', { replace: true });
|
||||
}
|
||||
}, [location.pathname, opened, navigate]);
|
||||
|
||||
// Handle custom events for backwards compatibility
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
navigate(`/settings/${detail.key}`);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
}, []);
|
||||
}, [navigate]);
|
||||
|
||||
const colors = useMemo(() => ({
|
||||
navBg: 'var(--modal-nav-bg)',
|
||||
@@ -40,23 +64,19 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
|
||||
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
|
||||
const handleLogout = () => {
|
||||
// In SaaS this would sign out, in open-source it does nothing
|
||||
console.log('Logout placeholder for SaaS compatibility');
|
||||
};
|
||||
|
||||
// Get isAdmin from app config (based on JWT role)
|
||||
// Get isAdmin and runningEE from app config
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
|
||||
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
Overview,
|
||||
handleLogout,
|
||||
isAdmin
|
||||
isAdmin,
|
||||
runningEE
|
||||
),
|
||||
[isAdmin]
|
||||
[isAdmin, runningEE]
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
@@ -75,10 +95,16 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const handleClose = () => {
|
||||
// Navigate back to home when closing modal
|
||||
navigate('/', { replace: true });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
@@ -109,15 +135,24 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map(item => {
|
||||
const isActive = active === item.key;
|
||||
const isDisabled = item.disabled ?? false;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
|
||||
const navItemContent = (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
setActive(item.key);
|
||||
navigate(`/settings/${item.key}`);
|
||||
}
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
@@ -128,6 +163,20 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return isDisabled && item.disabledTooltip ? (
|
||||
<Tooltip
|
||||
key={item.key}
|
||||
label={item.disabledTooltip}
|
||||
position="right"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{navItemContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={item.key}>{navItemContent}</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +196,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<ActionIcon variant="subtle" onClick={handleClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import { ActionIcon, Stack, Divider } from "@mantine/core";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
@@ -34,6 +37,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const isOverflow = useIsOverflowing(scrollableRef);
|
||||
|
||||
// Open modal if URL is at /settings/*
|
||||
useEffect(() => {
|
||||
const isSettings = location.pathname.startsWith('/settings');
|
||||
setConfigModalOpen(isSettings);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = getActiveNavButton(selectedToolKey, readerMode);
|
||||
setActiveButton(next);
|
||||
@@ -180,6 +189,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
navigate('/settings/overview');
|
||||
setConfigModalOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from 'react';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
|
||||
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
|
||||
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
|
||||
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
|
||||
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
|
||||
@@ -14,12 +12,16 @@ import AdminLegalSection from '@app/components/shared/config/configSections/Admi
|
||||
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
|
||||
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
|
||||
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
|
||||
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
|
||||
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
@@ -38,39 +40,10 @@ export interface ConfigColors {
|
||||
}
|
||||
|
||||
export const createConfigNavSections = (
|
||||
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
|
||||
onLogoutClick: () => void,
|
||||
isAdmin: boolean = false
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
icon: 'person-rounded',
|
||||
component: <Overview onLogoutClick={onLogoutClick} />
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
items: [
|
||||
{
|
||||
key: 'people',
|
||||
label: 'People',
|
||||
icon: 'group-rounded',
|
||||
component: <PeopleSection />
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
label: 'Teams',
|
||||
icon: 'groups-rounded',
|
||||
component: <TeamsSection />
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Preferences',
|
||||
items: [
|
||||
@@ -90,53 +63,18 @@ export const createConfigNavSections = (
|
||||
},
|
||||
];
|
||||
|
||||
// Add Admin Settings section if user is admin
|
||||
// Add Admin sections if user is admin
|
||||
if (isAdmin) {
|
||||
// Configuration
|
||||
sections.push({
|
||||
title: 'Admin Settings',
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{
|
||||
key: 'adminGeneral',
|
||||
label: 'General',
|
||||
label: 'System Settings',
|
||||
icon: 'settings-rounded',
|
||||
component: <AdminGeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'adminSecurity',
|
||||
label: 'Security',
|
||||
icon: 'shield-rounded',
|
||||
component: <AdminSecuritySection />
|
||||
},
|
||||
{
|
||||
key: 'adminConnections',
|
||||
label: 'Connections',
|
||||
icon: 'link-rounded',
|
||||
component: <AdminConnectionsSection />
|
||||
},
|
||||
{
|
||||
key: 'adminLegal',
|
||||
label: 'Legal',
|
||||
icon: 'gavel-rounded',
|
||||
component: <AdminLegalSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPrivacy',
|
||||
label: 'Privacy',
|
||||
icon: 'visibility-rounded',
|
||||
component: <AdminPrivacySection />
|
||||
},
|
||||
{
|
||||
key: 'adminDatabase',
|
||||
label: 'Database',
|
||||
icon: 'storage-rounded',
|
||||
component: <AdminDatabaseSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPremium',
|
||||
label: 'Premium',
|
||||
icon: 'star-rounded',
|
||||
component: <AdminPremiumSection />
|
||||
},
|
||||
{
|
||||
key: 'adminFeatures',
|
||||
label: 'Features',
|
||||
@@ -149,6 +87,12 @@ export const createConfigNavSections = (
|
||||
icon: 'api-rounded',
|
||||
component: <AdminEndpointsSection />
|
||||
},
|
||||
{
|
||||
key: 'adminDatabase',
|
||||
label: 'Database',
|
||||
icon: 'storage-rounded',
|
||||
component: <AdminDatabaseSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAdvanced',
|
||||
label: 'Advanced',
|
||||
@@ -157,6 +101,73 @@ export const createConfigNavSections = (
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Security & Authentication
|
||||
sections.push({
|
||||
title: 'Security & Authentication',
|
||||
items: [
|
||||
{
|
||||
key: 'adminSecurity',
|
||||
label: 'Security',
|
||||
icon: 'shield-rounded',
|
||||
component: <AdminSecuritySection />
|
||||
},
|
||||
{
|
||||
key: 'adminConnections',
|
||||
label: 'Connections',
|
||||
icon: 'link-rounded',
|
||||
component: <AdminConnectionsSection />
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Licensing & Analytics
|
||||
sections.push({
|
||||
title: 'Licensing & Analytics',
|
||||
items: [
|
||||
{
|
||||
key: 'adminPremium',
|
||||
label: 'Premium',
|
||||
icon: 'star-rounded',
|
||||
component: <AdminPremiumSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAudit',
|
||||
label: 'Audit',
|
||||
icon: 'fact-check-rounded',
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
{
|
||||
key: 'adminUsage',
|
||||
label: 'Usage Analytics',
|
||||
icon: 'analytics-rounded',
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Policies & Privacy
|
||||
sections.push({
|
||||
title: 'Policies & Privacy',
|
||||
items: [
|
||||
{
|
||||
key: 'adminLegal',
|
||||
label: 'Legal',
|
||||
icon: 'gavel-rounded',
|
||||
component: <AdminLegalSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPrivacy',
|
||||
label: 'Privacy',
|
||||
icon: 'visibility-rounded',
|
||||
component: <AdminPrivacySection />
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs, Loader, Alert, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||
import AuditSystemStatus from '@app/components/shared/config/configSections/audit/AuditSystemStatus';
|
||||
import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
|
||||
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
|
||||
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
|
||||
|
||||
const AdminAuditSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const status = await auditService.getSystemStatus();
|
||||
setSystemStatus(status);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load audit system status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemStatus();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('audit.error.title', 'Error loading audit system')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemStatus) {
|
||||
return (
|
||||
<Alert color="yellow" title={t('audit.notAvailable', 'Audit system not available')}>
|
||||
{t('audit.notAvailableMessage', 'The audit system is not configured or not available.')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<AuditSystemStatus status={systemStatus} />
|
||||
|
||||
{systemStatus.enabled ? (
|
||||
<Tabs defaultValue="dashboard">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="dashboard">
|
||||
{t('audit.tabs.dashboard', 'Dashboard')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="events">
|
||||
{t('audit.tabs.events', 'Audit Events')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="export">
|
||||
{t('audit.tabs.export', 'Export')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="dashboard" pt="md">
|
||||
<AuditChartsSection />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="events" pt="md">
|
||||
<AuditEventsTable />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="export" pt="md">
|
||||
<AuditExportSection />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Alert color="blue" title={t('audit.disabled', 'Audit logging is disabled')}>
|
||||
{t(
|
||||
'audit.disabledMessage',
|
||||
'Enable audit logging in your application configuration to track system events.'
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAuditSection;
|
||||
@@ -165,9 +165,9 @@ export default function AdminGeneralSection() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.general.title', 'General')}</Text>
|
||||
<Text fw={600} size="lg">{t('admin.settings.general.title', 'System Settings')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.general.description', 'Configure general application settings including branding and default behaviour.')}
|
||||
{t('admin.settings.general.description', 'Configure system-wide application settings including branding and default behaviour.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,17 +6,27 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface MailSettingsData {
|
||||
enabled?: boolean;
|
||||
enableInvites?: boolean;
|
||||
inviteLinkExpiryHours?: number;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
from?: string;
|
||||
frontendUrl?: string;
|
||||
}
|
||||
|
||||
interface ApiResponseWithPending<T> {
|
||||
_pending?: Partial<T>;
|
||||
}
|
||||
|
||||
type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsData>;
|
||||
type SystemApiResponse = { frontendUrl?: string } & ApiResponseWithPending<{ frontendUrl?: string }>;
|
||||
|
||||
export default function AdminMailSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
@@ -31,6 +41,47 @@ export default function AdminMailSection() {
|
||||
isFieldPending,
|
||||
} = useAdminSettings<MailSettingsData>({
|
||||
sectionName: 'mail',
|
||||
fetchTransformer: async () => {
|
||||
const [mailResponse, systemResponse] = await Promise.all([
|
||||
apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail'),
|
||||
apiClient.get<SystemApiResponse>('/api/v1/admin/settings/section/system')
|
||||
]);
|
||||
|
||||
const mail = mailResponse.data || {};
|
||||
const system = systemResponse.data || {};
|
||||
|
||||
const result: MailSettingsData & ApiResponseWithPending<MailSettingsData> = {
|
||||
...mail,
|
||||
frontendUrl: system.frontendUrl || ''
|
||||
};
|
||||
|
||||
// Merge pending blocks from both endpoints
|
||||
const pendingBlock: Partial<MailSettingsData> = {};
|
||||
if (mail._pending) {
|
||||
Object.assign(pendingBlock, mail._pending);
|
||||
}
|
||||
if (system._pending?.frontendUrl !== undefined) {
|
||||
pendingBlock.frontendUrl = system._pending.frontendUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
const { frontendUrl, ...mailSettings } = settings;
|
||||
|
||||
const deltaSettings: Record<string, any> = {
|
||||
'system.frontendUrl': frontendUrl
|
||||
};
|
||||
|
||||
return {
|
||||
sectionData: mailSettings,
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -175,6 +226,21 @@ export default function AdminMailSection() {
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.mail.frontendUrl', 'Frontend URL')}</span>
|
||||
<PendingBadge show={isFieldPending('frontendUrl')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.mail.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.')}
|
||||
value={settings.frontendUrl || ''}
|
||||
onChange={(e) => setSettings({ ...settings, frontendUrl: e.target.value })}
|
||||
placeholder="https://pdf.example.com"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Alert, List } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
@@ -73,12 +73,12 @@ export default function AdminPremiumSection() {
|
||||
<Text size="sm">
|
||||
{t('admin.settings.premium.movedFeatures.message', 'Premium and Enterprise features are now organized in their respective sections:')}
|
||||
</Text>
|
||||
<ul style={{ marginTop: '8px', marginBottom: 0, paddingLeft: '20px' }}>
|
||||
<li><Text size="sm" component="span"><strong>SSO Auto Login</strong> (PRO) - Connections</Text></li>
|
||||
<li><Text size="sm" component="span"><strong>Custom Metadata</strong> (PRO) - General</Text></li>
|
||||
<li><Text size="sm" component="span"><strong>Audit Logging</strong> (ENTERPRISE) - Security</Text></li>
|
||||
<li><Text size="sm" component="span"><strong>Database Configuration</strong> (ENTERPRISE) - Database</Text></li>
|
||||
</ul>
|
||||
<List mt="xs" size="sm">
|
||||
<List.Item><Text size="sm" component="span"><strong>SSO Auto Login</strong> (PRO) - Connections</Text></List.Item>
|
||||
<List.Item><Text size="sm" component="span"><strong>Custom Metadata</strong> (PRO) - General</Text></List.Item>
|
||||
<List.Item><Text size="sm" component="span"><strong>Audit Logging</strong> (ENTERPRISE) - Security</Text></List.Item>
|
||||
<List.Item><Text size="sm" component="span"><strong>Database Configuration</strong> (ENTERPRISE) - Database</Text></List.Item>
|
||||
</List>
|
||||
</Alert>
|
||||
|
||||
{/* License Configuration */}
|
||||
|
||||
+157
-77
@@ -67,32 +67,35 @@ export default function AdminSecuritySection() {
|
||||
const premiumData = premiumResponse.data || {};
|
||||
const systemData = systemResponse.data || {};
|
||||
|
||||
console.log('[AdminSecuritySection] Raw backend data:');
|
||||
console.log('Security:', JSON.parse(JSON.stringify(securityData)));
|
||||
console.log('Premium:', JSON.parse(JSON.stringify(premiumData)));
|
||||
console.log('System:', JSON.parse(JSON.stringify(systemData)));
|
||||
|
||||
const { _pending: securityPending, ...securityActive } = securityData;
|
||||
const { _pending: premiumPending, ...premiumActive } = premiumData;
|
||||
const { _pending: systemPending, ...systemActive } = systemData;
|
||||
|
||||
console.log('[AdminSecuritySection] Extracted pending blocks:', {
|
||||
securityPending: JSON.parse(JSON.stringify(securityPending || {})),
|
||||
premiumPending: JSON.parse(JSON.stringify(premiumPending || {})),
|
||||
systemPending: JSON.parse(JSON.stringify(systemPending || {}))
|
||||
});
|
||||
|
||||
const combined: any = {
|
||||
...securityActive,
|
||||
audit: premiumActive.enterpriseFeatures?.audit || {
|
||||
enabled: false,
|
||||
level: 2,
|
||||
retentionDays: 90
|
||||
},
|
||||
html: systemActive.html || {
|
||||
urlSecurity: {
|
||||
enabled: true,
|
||||
level: 'MEDIUM',
|
||||
allowedDomains: [],
|
||||
blockedDomains: [],
|
||||
internalTlds: ['.local', '.internal', '.corp', '.home'],
|
||||
blockPrivateNetworks: true,
|
||||
blockLocalhost: true,
|
||||
blockLinkLocal: true,
|
||||
blockCloudMetadata: true
|
||||
}
|
||||
}
|
||||
...securityActive
|
||||
};
|
||||
|
||||
// Only add audit if it exists (don't create defaults)
|
||||
if (premiumActive.enterpriseFeatures?.audit) {
|
||||
combined.audit = premiumActive.enterpriseFeatures.audit;
|
||||
}
|
||||
|
||||
// Only add html if it exists (don't create defaults)
|
||||
if (systemActive.html) {
|
||||
combined.html = systemActive.html;
|
||||
}
|
||||
|
||||
// Merge all _pending blocks
|
||||
const mergedPending: any = {};
|
||||
if (securityPending) {
|
||||
@@ -115,11 +118,25 @@ export default function AdminSecuritySection() {
|
||||
const { audit, html, ...securitySettings } = settings;
|
||||
|
||||
const deltaSettings: Record<string, any> = {
|
||||
// Security settings
|
||||
'security.enableLogin': securitySettings.enableLogin,
|
||||
'security.csrfDisabled': securitySettings.csrfDisabled,
|
||||
'security.loginMethod': securitySettings.loginMethod,
|
||||
'security.loginAttemptCount': securitySettings.loginAttemptCount,
|
||||
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
|
||||
// JWT settings
|
||||
'security.jwt.persistence': securitySettings.jwt?.persistence,
|
||||
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
|
||||
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
|
||||
'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays,
|
||||
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
|
||||
// Premium audit settings
|
||||
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
||||
'premium.enterpriseFeatures.audit.level': audit?.level,
|
||||
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
|
||||
};
|
||||
|
||||
// System HTML settings
|
||||
if (html?.urlSecurity) {
|
||||
deltaSettings['system.html.urlSecurity.enabled'] = html.urlSecurity.enabled;
|
||||
deltaSettings['system.html.urlSecurity.level'] = html.urlSecurity.level;
|
||||
@@ -133,7 +150,7 @@ export default function AdminSecuritySection() {
|
||||
}
|
||||
|
||||
return {
|
||||
sectionData: securitySettings,
|
||||
sectionData: {},
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
@@ -217,7 +234,12 @@ export default function AdminSecuritySection() {
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}</span>
|
||||
<PendingBadge show={isFieldPending('loginAttemptCount')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
|
||||
value={settings.loginAttemptCount || 0}
|
||||
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
|
||||
@@ -228,7 +250,12 @@ export default function AdminSecuritySection() {
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}</span>
|
||||
<PendingBadge show={isFieldPending('loginResetTimeMinutes')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
|
||||
value={settings.loginResetTimeMinutes || 0}
|
||||
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
|
||||
@@ -295,10 +322,13 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.jwt?.enableKeyRotation || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.jwt?.enableKeyRotation || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('jwt.enableKeyRotation')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -308,15 +338,23 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.jwt?.enableKeyCleanup || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.jwt?.enableKeyCleanup || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('jwt.enableKeyCleanup')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
|
||||
value={settings.jwt?.keyRetentionDays || 7}
|
||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
|
||||
@@ -369,7 +407,12 @@ export default function AdminSecuritySection() {
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.security.audit.level', 'Audit Level')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.audit.level', 'Audit Level')}</span>
|
||||
<PendingBadge show={isFieldPending('audit.level')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
|
||||
value={settings.audit?.level || 2}
|
||||
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })}
|
||||
@@ -380,7 +423,12 @@ export default function AdminSecuritySection() {
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}</span>
|
||||
<PendingBadge show={isFieldPending('audit.retentionDays')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')}
|
||||
value={settings.audit?.retentionDays || 90}
|
||||
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })}
|
||||
@@ -425,7 +473,12 @@ export default function AdminSecuritySection() {
|
||||
|
||||
<div>
|
||||
<Select
|
||||
label={t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}</span>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.level')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.htmlUrlSecurity.level.description', 'MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions')}
|
||||
value={settings.html?.urlSecurity?.level || 'MEDIUM'}
|
||||
onChange={(value) => setSettings({
|
||||
@@ -452,7 +505,12 @@ export default function AdminSecuritySection() {
|
||||
{/* Allowed Domains */}
|
||||
<div>
|
||||
<Textarea
|
||||
label={t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}</span>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.allowedDomains')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.htmlUrlSecurity.allowedDomains.description', 'One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX')}
|
||||
value={settings.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
|
||||
onChange={(e) => setSettings({
|
||||
@@ -474,7 +532,12 @@ export default function AdminSecuritySection() {
|
||||
{/* Blocked Domains */}
|
||||
<div>
|
||||
<Textarea
|
||||
label={t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}</span>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockedDomains')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.htmlUrlSecurity.blockedDomains.description', 'One domain per line (e.g., malicious.com). Additional domains to block')}
|
||||
value={settings.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
|
||||
onChange={(e) => setSettings({
|
||||
@@ -496,7 +559,12 @@ export default function AdminSecuritySection() {
|
||||
{/* Internal TLDs */}
|
||||
<div>
|
||||
<Textarea
|
||||
label={t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}</span>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.internalTlds')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.htmlUrlSecurity.internalTlds.description', 'One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns')}
|
||||
value={settings.html?.urlSecurity?.internalTlds?.join('\n') || ''}
|
||||
onChange={(e) => setSettings({
|
||||
@@ -525,16 +593,19 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.description', 'Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockPrivateNetworks')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -544,16 +615,19 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.description', 'Block localhost and loopback addresses (127.x.x.x, ::1)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockLocalhost || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockLocalhost || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockLocalhost')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -563,16 +637,19 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.description', 'Block link-local addresses (169.254.x.x, fe80::/10)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockLinkLocal')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -582,16 +659,19 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.description', 'Block cloud provider metadata endpoints (169.254.169.254)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockCloudMetadata')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
Loader,
|
||||
Alert,
|
||||
Card,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
|
||||
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
|
||||
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
const AdminUsageSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
|
||||
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
|
||||
const response = await usageAnalyticsService.getEndpointStatistics(limit, dataType);
|
||||
|
||||
setData(response);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load usage statistics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [displayMode, dataType]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const getDisplayModeLabel = () => {
|
||||
switch (displayMode) {
|
||||
case 'top10':
|
||||
return t('usage.showing.top10', 'Top 10');
|
||||
case 'top20':
|
||||
return t('usage.showing.top20', 'Top 20');
|
||||
case 'all':
|
||||
return t('usage.showing.all', 'All');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Early returns for loading/error states
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('usage.error', 'Error loading usage statistics')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="yellow" title={t('usage.noData', 'No data available')}>
|
||||
{t('usage.noDataMessage', 'No usage statistics are currently available.')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = data.endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
|
||||
|
||||
const displayedVisits = data.endpoints.reduce((sum, e) => sum + e.visits, 0);
|
||||
|
||||
const displayedPercentage = data.totalVisits > 0
|
||||
? ((displayedVisits / data.totalVisits) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Controls */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<SegmentedControl
|
||||
value={displayMode}
|
||||
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
|
||||
data={[
|
||||
{
|
||||
value: 'top10',
|
||||
label: t('usage.controls.top10', 'Top 10'),
|
||||
},
|
||||
{
|
||||
value: 'top20',
|
||||
label: t('usage.controls.top20', 'Top 20'),
|
||||
},
|
||||
{
|
||||
value: 'all',
|
||||
label: t('usage.controls.all', 'All'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
>
|
||||
{t('usage.controls.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('usage.controls.dataTypeLabel', 'Data Type:')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={dataType}
|
||||
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
|
||||
data={[
|
||||
{
|
||||
value: 'all',
|
||||
label: t('usage.controls.dataType.all', 'All'),
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
label: t('usage.controls.dataType.api', 'API'),
|
||||
},
|
||||
{
|
||||
value: 'ui',
|
||||
label: t('usage.controls.dataType.ui', 'UI'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Statistics Summary */}
|
||||
<Group gap="xl" style={{ flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{data.totalEndpoints}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.totalVisits', 'Total Visits')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{data.totalVisits.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.showing', 'Showing')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{getDisplayModeLabel()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.selectedVisits', 'Selected Visits')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{displayedVisits.toLocaleString()} ({displayedPercentage}%)
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Chart and Table */}
|
||||
<UsageAnalyticsChart data={chartData} />
|
||||
<UsageAnalyticsTable data={data.endpoints} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsageSection;
|
||||
@@ -1,29 +1,98 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { ToolPanelMode } from '@app/constants/toolPanel';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
||||
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
|
||||
|
||||
const GeneralSection: React.FC = () => {
|
||||
interface GeneralSectionProps {
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
|
||||
const [bannerDismissed, setBannerDismissed] = useState(() => {
|
||||
// Check localStorage on mount
|
||||
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
|
||||
});
|
||||
|
||||
// Sync local state with preference changes
|
||||
useEffect(() => {
|
||||
setFileLimitInput(preferences.autoUnzipFileLimit);
|
||||
}, [preferences.autoUnzipFileLimit]);
|
||||
|
||||
// Check if login is disabled
|
||||
const loginDisabled = !config?.enableLogin;
|
||||
|
||||
const handleDismissBanner = () => {
|
||||
setBannerDismissed(true);
|
||||
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.description', 'Configure general application preferences.')}
|
||||
</Text>
|
||||
</div>
|
||||
{!hideTitle && (
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.description', 'Configure general application preferences.')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginDisabled && !bannerDismissed && (
|
||||
<Paper withBorder p="md" radius="md" style={{ background: 'var(--mantine-color-blue-0)', position: 'relative' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
onClick={handleDismissBanner}
|
||||
aria-label={t('settings.general.enableFeatures.dismiss', 'Dismiss')}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<LocalIcon icon="admin-panel-settings-rounded" width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)' }} />
|
||||
<Text fw={600} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
|
||||
{t('settings.general.enableFeatures.title', 'For System Administrators')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.enableFeatures.intro', 'Enable user authentication, team management, and workspace features for your organization.')}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.enableFeatures.action', 'Configure')}
|
||||
</Text>
|
||||
<Code>SECURITY_ENABLELOGIN=true</Code>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.enableFeatures.and', 'and')}
|
||||
</Text>
|
||||
<Code>DISABLE_ADDITIONAL_FEATURES=false</Code>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t('settings.general.enableFeatures.benefit', 'Enables user roles, team collaboration, admin controls, and enterprise features.')}
|
||||
</Text>
|
||||
<Anchor
|
||||
href="https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
style={{ color: 'var(--mantine-color-blue-6)' }}
|
||||
>
|
||||
{t('settings.general.enableFeatures.learnMore', 'Learn more in documentation')} →
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert, Box, SimpleGrid } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditChartsData } from '@app/services/auditService';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
title: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, title, color = 'blue' }) => {
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{data.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" maw={200} style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{item.value}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '0.5rem',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: `var(--mantine-color-${color}-6)`,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AuditChartsSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
|
||||
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChartsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await auditService.getChartsData(timePeriod);
|
||||
setChartsData(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load charts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChartsData();
|
||||
}, [timePeriod]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="center">
|
||||
<Loader size="lg" my="xl" />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('audit.charts.error', 'Error loading charts')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventsByTypeData = chartsData.eventsByType.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsByType.values[index],
|
||||
}));
|
||||
|
||||
const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsByUser.values[index],
|
||||
}));
|
||||
|
||||
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsOverTime.values[index],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.charts.title', 'Audit Dashboard')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={timePeriod}
|
||||
onChange={(value) => setTimePeriod(value as 'day' | 'week' | 'month')}
|
||||
data={[
|
||||
{ label: t('audit.charts.day', 'Day'), value: 'day' },
|
||||
{ label: t('audit.charts.week', 'Week'), value: 'week' },
|
||||
{ label: t('audit.charts.month', 'Month'), value: 'month' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={3} spacing="xl">
|
||||
<SimpleBarChart
|
||||
data={eventsByTypeData}
|
||||
title={t('audit.charts.byType', 'Events by Type')}
|
||||
color="blue"
|
||||
/>
|
||||
<SimpleBarChart
|
||||
data={eventsByUserData}
|
||||
title={t('audit.charts.byUser', 'Events by User')}
|
||||
color="green"
|
||||
/>
|
||||
<SimpleBarChart
|
||||
data={eventsOverTimeData}
|
||||
title={t('audit.charts.overTime', 'Events Over Time')}
|
||||
color="purple"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditChartsSection;
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Button,
|
||||
Pagination,
|
||||
Modal,
|
||||
Code,
|
||||
Loader,
|
||||
Alert,
|
||||
Table,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditEvent } from '@app/services/auditService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { useAuditFilters } from '@app/hooks/useAuditFilters';
|
||||
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
|
||||
|
||||
const AuditEventsTable: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
|
||||
|
||||
// Use shared filters hook
|
||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({
|
||||
page: 0,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await auditService.getEvents({
|
||||
...filters,
|
||||
page: currentPage - 1,
|
||||
});
|
||||
setEvents(response.events);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEvents();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
// Wrap filter handlers to reset pagination
|
||||
const handleFilterChangeWithReset = (key: keyof typeof filters, value: any) => {
|
||||
handleFilterChange(key, value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleClearFiltersWithReset = () => {
|
||||
handleClearFilters();
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.events.title', 'Audit Events')}
|
||||
</Text>
|
||||
|
||||
{/* Filters */}
|
||||
<AuditFiltersForm
|
||||
filters={filters}
|
||||
eventTypes={eventTypes}
|
||||
users={users}
|
||||
onFilterChange={handleFilterChangeWithReset}
|
||||
onClearFilters={handleClearFiltersWithReset}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Loader size="lg" my="xl" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert color="red" title={t('audit.events.error', 'Error loading events')}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.timestamp', 'Timestamp')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.type', 'Type')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.user', 'User')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.ipAddress', 'IP Address')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" ta="center">
|
||||
{t('audit.events.actions', 'Actions')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{events.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('audit.events.noEvents', 'No events found')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<Table.Tr key={event.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatDate(event.timestamp)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.eventType}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.username}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.ipAddress}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
>
|
||||
{t('audit.events.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="md">
|
||||
<Pagination
|
||||
value={currentPage}
|
||||
onChange={setCurrentPage}
|
||||
total={totalPages}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Event Details Modal */}
|
||||
<Modal
|
||||
opened={selectedEvent !== null}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
title={t('audit.events.eventDetails', 'Event Details')}
|
||||
size="lg"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{selectedEvent && (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.timestamp', 'Timestamp')}
|
||||
</Text>
|
||||
<Text size="sm">{formatDate(selectedEvent.timestamp)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.type', 'Type')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.eventType}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.user', 'User')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.username}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.ipAddress', 'IP Address')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.ipAddress}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.details', 'Details')}
|
||||
</Text>
|
||||
<Code block mah={300} style={{ overflow: 'auto' }}>
|
||||
{JSON.stringify(selectedEvent.details, null, 2)}
|
||||
</Code>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditEventsTable;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService from '@app/services/auditService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useAuditFilters } from '@app/hooks/useAuditFilters';
|
||||
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
|
||||
|
||||
const AuditExportSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Use shared filters hook
|
||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters();
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
|
||||
const blob = await auditService.exportData(exportFormat, filters);
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
alert(t('audit.export.error', 'Failed to export data'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.export.title', 'Export Audit Data')}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'audit.export.description',
|
||||
'Export audit events to CSV or JSON format. Use filters to limit the exported data.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t('audit.export.format', 'Export Format')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={exportFormat}
|
||||
onChange={(value) => setExportFormat(value as 'csv' | 'json')}
|
||||
data={[
|
||||
{ label: 'CSV', value: 'csv' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t('audit.export.filters', 'Filters (Optional)')}
|
||||
</Text>
|
||||
<AuditFiltersForm
|
||||
filters={filters}
|
||||
eventTypes={eventTypes}
|
||||
users={users}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="download" width="1rem" height="1rem" />}
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={exporting}
|
||||
>
|
||||
{t('audit.export.exportButton', 'Export Data')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditExportSection;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Group, Select, Button } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuditFilters } from '@app/services/auditService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AuditFiltersFormProps {
|
||||
filters: AuditFilters;
|
||||
eventTypes: string[];
|
||||
users: string[];
|
||||
onFilterChange: (key: keyof AuditFilters, value: any) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared filter form for audit components
|
||||
*/
|
||||
const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
|
||||
filters,
|
||||
eventTypes,
|
||||
users,
|
||||
onFilterChange,
|
||||
onClearFilters,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Select
|
||||
placeholder={t('audit.events.filterByType', 'Filter by type')}
|
||||
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
||||
value={filters.eventType}
|
||||
onChange={(value) => onFilterChange('eventType', value || undefined)}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('audit.events.filterByUser', 'Filter by user')}
|
||||
data={users.map((user) => ({ value: user, label: user }))}
|
||||
value={filters.username}
|
||||
onChange={(value) => onFilterChange('username', value || undefined)}
|
||||
clearable
|
||||
searchable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.startDate', 'Start date')}
|
||||
value={filters.startDate ? new Date(filters.startDate) : null}
|
||||
onChange={(value: string | null) =>
|
||||
onFilterChange('startDate', value ?? undefined)
|
||||
}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 150 }}
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.endDate', 'End date')}
|
||||
value={filters.endDate ? new Date(filters.endDate) : null}
|
||||
onChange={(value: string | null) =>
|
||||
onFilterChange('endDate', value ?? undefined)
|
||||
}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 150 }}
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Button variant="outline" onClick={onClearFilters}>
|
||||
{t('audit.events.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditFiltersForm;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Card, Group, Stack, Badge, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||
|
||||
interface AuditSystemStatusProps {
|
||||
status: AuditStatus;
|
||||
}
|
||||
|
||||
const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.systemStatus.title', 'System Status')}
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.status', 'Audit Logging')}
|
||||
</Text>
|
||||
<Badge color={status.enabled ? 'green' : 'red'} variant="light" size="lg" mt="xs">
|
||||
{status.enabled
|
||||
? t('audit.systemStatus.enabled', 'Enabled')
|
||||
: t('audit.systemStatus.disabled', 'Disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.level', 'Audit Level')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.level}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.retention', 'Retention Period')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.retentionDays} {t('audit.systemStatus.days', 'days')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.totalEvents', 'Total Events')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.totalEvents.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditSystemStatus;
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Stack, Group, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{data.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('usage.noData', 'No data available')}
|
||||
</Text>
|
||||
) : (
|
||||
data.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
maw="60%"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" fw={600}>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
({((item.value / maxValue) * 100).toFixed(1)}%)
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '0.5rem',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface UsageAnalyticsChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
}
|
||||
|
||||
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('usage.chart.title', 'Endpoint Usage Chart')}
|
||||
</Text>
|
||||
<SimpleBarChart data={data} maxValue={Math.max(...data.map((d) => d.value), 1)} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageAnalyticsChart;
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Stack, Table } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EndpointStatistic } from '@app/services/usageAnalyticsService';
|
||||
|
||||
interface UsageAnalyticsTableProps {
|
||||
data: EndpointStatistic[];
|
||||
}
|
||||
|
||||
const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('usage.table.title', 'Detailed Statistics')}
|
||||
</Text>
|
||||
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="5%">
|
||||
#
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="55%">
|
||||
{t('usage.table.endpoint', 'Endpoint')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
|
||||
{t('usage.table.visits', 'Visits')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
|
||||
{t('usage.table.percentage', 'Percentage')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('usage.table.noData', 'No data available')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
data.map((stat, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{index + 1}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" truncate>
|
||||
{stat.endpoint}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" fw={600}>
|
||||
{stat.visits.toLocaleString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" c="dimmed">
|
||||
{stat.percentage.toFixed(2)}%
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageAnalyticsTable;
|
||||
@@ -1,29 +1,35 @@
|
||||
export type NavKey =
|
||||
| 'overview'
|
||||
| 'preferences'
|
||||
| 'notifications'
|
||||
| 'connections'
|
||||
| 'general'
|
||||
| 'people'
|
||||
| 'teams'
|
||||
| 'security'
|
||||
| 'identity'
|
||||
| 'plan'
|
||||
| 'payments'
|
||||
| 'requests'
|
||||
| 'developer'
|
||||
| 'api-keys'
|
||||
| 'hotkeys'
|
||||
| 'adminGeneral'
|
||||
| 'adminSecurity'
|
||||
| 'adminConnections'
|
||||
| 'adminPrivacy'
|
||||
| 'adminDatabase'
|
||||
| 'adminAdvanced'
|
||||
| 'adminLegal'
|
||||
| 'adminPremium'
|
||||
| 'adminFeatures'
|
||||
| 'adminEndpoints';
|
||||
// Single source of truth for all valid nav keys
|
||||
export const VALID_NAV_KEYS = [
|
||||
'preferences',
|
||||
'notifications',
|
||||
'connections',
|
||||
'general',
|
||||
'people',
|
||||
'teams',
|
||||
'security',
|
||||
'identity',
|
||||
'plan',
|
||||
'payments',
|
||||
'requests',
|
||||
'developer',
|
||||
'api-keys',
|
||||
'hotkeys',
|
||||
'adminGeneral',
|
||||
'adminSecurity',
|
||||
'adminConnections',
|
||||
'adminPrivacy',
|
||||
'adminDatabase',
|
||||
'adminAdvanced',
|
||||
'adminLegal',
|
||||
'adminPremium',
|
||||
'adminFeatures',
|
||||
'adminPlan',
|
||||
'adminAudit',
|
||||
'adminUsage',
|
||||
'adminEndpoints',
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
export type NavKey = typeof VALID_NAV_KEYS[number];
|
||||
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alert } from '@app/components/toast';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export function useRestartServer() {
|
||||
const { t } = useTranslation();
|
||||
@@ -27,18 +28,12 @@ export function useRestartServer() {
|
||||
),
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/admin/settings/restart', {
|
||||
method: 'POST',
|
||||
});
|
||||
await apiClient.post('/api/v1/admin/settings/restart');
|
||||
|
||||
if (response.ok) {
|
||||
// Wait a moment then reload the page
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
} else {
|
||||
throw new Error('Failed to restart');
|
||||
}
|
||||
// Wait a moment then reload the page
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
|
||||
@@ -71,15 +71,15 @@ export function useAdminSettings<T = any>(
|
||||
// Store raw settings (includes _pending if present)
|
||||
setRawSettings(rawData);
|
||||
|
||||
// Extract active settings (without _pending) for delta comparison
|
||||
const { _pending, ...activeOnly } = rawData;
|
||||
setOriginalSettings(activeOnly as T);
|
||||
console.log(`[useAdminSettings:${sectionName}] Original active settings:`, JSON.stringify(activeOnly, null, 2));
|
||||
|
||||
// Merge pending changes into settings for display
|
||||
const mergedSettings = mergePendingSettings(rawData);
|
||||
console.log(`[useAdminSettings:${sectionName}] Merged settings:`, JSON.stringify(mergedSettings, null, 2));
|
||||
|
||||
// Store merged settings as original for delta comparison
|
||||
// This ensures we compare against what the user SAW (with pending), not raw active values
|
||||
setOriginalSettings(mergedSettings as T);
|
||||
console.log(`[useAdminSettings:${sectionName}] Original settings (for comparison):`, JSON.stringify(mergedSettings, null, 2));
|
||||
|
||||
setSettings(mergedSettings as T);
|
||||
} catch (error) {
|
||||
console.error(`[useAdminSettings:${sectionName}] Failed to fetch:`, error);
|
||||
@@ -106,15 +106,46 @@ export function useAdminSettings<T = any>(
|
||||
// Use custom save logic for complex sections
|
||||
const { sectionData, deltaSettings } = saveTransformer(settings);
|
||||
|
||||
// Save section data (with delta applied)
|
||||
const sectionDelta = computeDelta(originalSettings, sectionData);
|
||||
// Get original sectionData using same transformer for fair comparison
|
||||
const { sectionData: originalSectionData } = saveTransformer(originalSettings);
|
||||
|
||||
// Save section data (with delta applied) - compare transformed vs transformed
|
||||
const sectionDelta = computeDelta(originalSectionData, sectionData);
|
||||
if (Object.keys(sectionDelta).length > 0) {
|
||||
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta);
|
||||
}
|
||||
|
||||
// Save delta settings if provided
|
||||
// Save delta settings if provided (filter to only changed values)
|
||||
if (deltaSettings && Object.keys(deltaSettings).length > 0) {
|
||||
await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
||||
// Build deltaSettings from original using same transformer to get correct structure
|
||||
const { deltaSettings: originalDeltaSettings } = saveTransformer(originalSettings);
|
||||
|
||||
console.log(`[useAdminSettings:${sectionName}] Comparing deltaSettings:`, {
|
||||
original: originalDeltaSettings,
|
||||
current: deltaSettings
|
||||
});
|
||||
|
||||
// Compare current vs original deltaSettings (both have same backend paths)
|
||||
const changedDeltaSettings: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(deltaSettings)) {
|
||||
const originalValue = originalDeltaSettings?.[key];
|
||||
|
||||
// Only include if value actually changed
|
||||
if (JSON.stringify(value) !== JSON.stringify(originalValue)) {
|
||||
changedDeltaSettings[key] = value;
|
||||
console.log(`[useAdminSettings:${sectionName}] Delta field changed: ${key}`, {
|
||||
original: originalValue,
|
||||
new: value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(changedDeltaSettings).length > 0) {
|
||||
console.log(`[useAdminSettings:${sectionName}] Sending delta settings:`, changedDeltaSettings);
|
||||
await apiClient.put('/api/v1/admin/settings', { settings: changedDeltaSettings });
|
||||
} else {
|
||||
console.log(`[useAdminSettings:${sectionName}] No delta settings changed, skipping`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Simple single-endpoint save with delta
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import auditService, { AuditFilters } from '@app/services/auditService';
|
||||
|
||||
/**
|
||||
* Shared hook for managing audit filters across components
|
||||
*/
|
||||
export function useAuditFilters(initialFilters: Partial<AuditFilters> = {}) {
|
||||
const [eventTypes, setEventTypes] = useState<string[]>([]);
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
const [filters, setFilters] = useState<AuditFilters>({
|
||||
eventType: undefined,
|
||||
username: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
...initialFilters,
|
||||
});
|
||||
|
||||
// Fetch metadata on mount
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const [types, usersList] = await Promise.all([
|
||||
auditService.getEventTypes(),
|
||||
auditService.getUsers(),
|
||||
]);
|
||||
setEventTypes(types);
|
||||
setUsers(usersList);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch audit metadata:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetadata();
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = (key: keyof AuditFilters, value: any) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
eventType: undefined,
|
||||
username: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
page: initialFilters.page,
|
||||
pageSize: initialFilters.pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
filters,
|
||||
setFilters,
|
||||
eventTypes,
|
||||
users,
|
||||
handleFilterChange,
|
||||
handleClearFilters,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface AuditSystemStatus {
|
||||
enabled: boolean;
|
||||
level: string;
|
||||
retentionDays: number;
|
||||
totalEvents: number;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
username: string;
|
||||
ipAddress: string;
|
||||
details: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AuditEventsResponse {
|
||||
events: AuditEvent[];
|
||||
totalEvents: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export interface AuditChartsData {
|
||||
eventsByType: ChartData;
|
||||
eventsByUser: ChartData;
|
||||
eventsOverTime: ChartData;
|
||||
}
|
||||
|
||||
export interface AuditFilters {
|
||||
eventType?: string;
|
||||
username?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const auditService = {
|
||||
/**
|
||||
* Get audit system status
|
||||
*/
|
||||
async getSystemStatus(): Promise<AuditSystemStatus> {
|
||||
const response = await apiClient.get<any>('/api/v1/proprietary/ui-data/audit-dashboard');
|
||||
const data = response.data;
|
||||
|
||||
// Map V1 response to expected format
|
||||
return {
|
||||
enabled: data.auditEnabled,
|
||||
level: data.auditLevel,
|
||||
retentionDays: data.retentionDays,
|
||||
totalEvents: 0, // Will be fetched separately
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get audit events with pagination and filters
|
||||
*/
|
||||
async getEvents(filters: AuditFilters = {}): Promise<AuditEventsResponse> {
|
||||
const response = await apiClient.get<AuditEventsResponse>('/api/v1/proprietary/ui-data/audit-events', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get chart data for dashboard
|
||||
*/
|
||||
async getChartsData(timePeriod: 'day' | 'week' | 'month' = 'week'): Promise<AuditChartsData> {
|
||||
const response = await apiClient.get<AuditChartsData>('/api/v1/proprietary/ui-data/audit-charts', {
|
||||
params: { period: timePeriod },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Export audit data
|
||||
*/
|
||||
async exportData(
|
||||
format: 'csv' | 'json',
|
||||
filters: AuditFilters = {}
|
||||
): Promise<Blob> {
|
||||
const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-export', {
|
||||
params: { format, ...filters },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get available event types for filtering
|
||||
*/
|
||||
async getEventTypes(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-event-types');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of users for filtering
|
||||
*/
|
||||
async getUsers(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-users');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default auditService;
|
||||
@@ -0,0 +1,61 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface EndpointStatistic {
|
||||
endpoint: string;
|
||||
visits: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface EndpointStatisticsResponse {
|
||||
endpoints: EndpointStatistic[];
|
||||
totalEndpoints: number;
|
||||
totalVisits: number;
|
||||
}
|
||||
|
||||
export interface UsageChartData {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
const usageAnalyticsService = {
|
||||
/**
|
||||
* Get endpoint statistics
|
||||
*/
|
||||
async getEndpointStatistics(
|
||||
limit?: number,
|
||||
dataType: 'all' | 'api' | 'ui' = 'all'
|
||||
): Promise<EndpointStatisticsResponse> {
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
if (limit !== undefined) {
|
||||
params.limit = limit;
|
||||
}
|
||||
|
||||
if (dataType !== 'all') {
|
||||
params.dataType = dataType;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<EndpointStatisticsResponse>(
|
||||
'/api/v1/proprietary/ui-data/usage-endpoint-statistics',
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get chart data for endpoint usage
|
||||
*/
|
||||
async getChartData(
|
||||
limit?: number,
|
||||
dataType: 'all' | 'api' | 'ui' = 'all'
|
||||
): Promise<UsageChartData> {
|
||||
const stats = await this.getEndpointStatistics(limit, dataType);
|
||||
|
||||
return {
|
||||
labels: stats.endpoints.map((e) => e.endpoint),
|
||||
values: stats.endpoints.map((e) => e.visits),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default usageAnalyticsService;
|
||||
@@ -31,6 +31,12 @@ export interface AdminSettingsData {
|
||||
roleDetails?: Record<string, string>;
|
||||
teams?: any[];
|
||||
maxPaidUsers?: number;
|
||||
// License information
|
||||
maxAllowedUsers: number;
|
||||
availableSlots: number;
|
||||
grandfatheredUserCount: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
@@ -62,6 +68,35 @@ export interface InviteUsersResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InviteLinkRequest {
|
||||
email: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
expiryHours?: number;
|
||||
sendEmail?: boolean;
|
||||
}
|
||||
|
||||
export interface InviteLinkResponse {
|
||||
token: string;
|
||||
inviteUrl: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
expiryHours: number;
|
||||
emailSent?: boolean;
|
||||
emailError?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InviteToken {
|
||||
id: number;
|
||||
email: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Management Service
|
||||
* Provides functions to interact with user management backend APIs
|
||||
@@ -163,4 +198,60 @@ export const userManagementService = {
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an invite link (admin only)
|
||||
*/
|
||||
async generateInviteLink(data: InviteLinkRequest): Promise<InviteLinkResponse> {
|
||||
const formData = new FormData();
|
||||
// Only append email if it's provided and not empty
|
||||
if (data.email && data.email.trim()) {
|
||||
formData.append('email', data.email);
|
||||
}
|
||||
formData.append('role', data.role);
|
||||
if (data.teamId) {
|
||||
formData.append('teamId', data.teamId.toString());
|
||||
}
|
||||
if (data.expiryHours) {
|
||||
formData.append('expiryHours', data.expiryHours.toString());
|
||||
}
|
||||
if (data.sendEmail !== undefined) {
|
||||
formData.append('sendEmail', data.sendEmail.toString());
|
||||
}
|
||||
|
||||
const response = await apiClient.post<InviteLinkResponse>(
|
||||
'/api/v1/invite/generate',
|
||||
formData,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
} as any
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of active invite links (admin only)
|
||||
*/
|
||||
async getInviteLinks(): Promise<InviteToken[]> {
|
||||
const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list');
|
||||
return response.data.invites;
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoke an invite link (admin only)
|
||||
*/
|
||||
async revokeInviteLink(inviteId: number): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clean up expired invite links (admin only)
|
||||
*/
|
||||
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
|
||||
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
|
||||
/**
|
||||
* Navigate to a specific settings section
|
||||
*
|
||||
* @param section - The settings section key to navigate to
|
||||
*
|
||||
* @example
|
||||
* // Navigate to People section
|
||||
* navigateToSettings('people');
|
||||
*
|
||||
* // Navigate to Admin Premium section
|
||||
* navigateToSettings('adminPremium');
|
||||
*/
|
||||
export function navigateToSettings(section: NavKey) {
|
||||
const basePath = window.location.pathname.split('/settings')[0] || '';
|
||||
const newPath = `${basePath}/settings/${section}`;
|
||||
window.history.pushState({}, '', newPath);
|
||||
|
||||
// Trigger a popstate event to notify components
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL path for a settings section
|
||||
* Useful for creating links
|
||||
*
|
||||
* @param section - The settings section key
|
||||
* @returns The URL path for the settings section
|
||||
*
|
||||
* @example
|
||||
* <a href={getSettingsUrl('people')}>Go to People Settings</a>
|
||||
* // Returns: "/settings/people"
|
||||
*/
|
||||
export function getSettingsUrl(section: NavKey): string {
|
||||
return `/settings/${section}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently viewing a settings section
|
||||
*
|
||||
* @param section - Optional section key to check for specific section
|
||||
* @returns True if in settings (and matching specific section if provided)
|
||||
*/
|
||||
export function isInSettings(section?: NavKey): boolean {
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
if (!section) {
|
||||
return pathname.startsWith('/settings');
|
||||
}
|
||||
|
||||
return pathname === `/settings/${section}`;
|
||||
}
|
||||
@@ -50,12 +50,19 @@ export function isFieldPending<T extends SettingsWithPending>(
|
||||
fieldPath: string
|
||||
): boolean {
|
||||
if (!settings?._pending) {
|
||||
console.log(`[isFieldPending] No _pending block found for field: ${fieldPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Navigate the pending object using dot notation
|
||||
const value = getNestedValue(settings._pending, fieldPath);
|
||||
return value !== undefined;
|
||||
const isPending = value !== undefined;
|
||||
|
||||
if (isPending) {
|
||||
console.log(`[isFieldPending] Field ${fieldPath} IS pending with value:`, value);
|
||||
}
|
||||
|
||||
return isPending;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import Landing from "@app/routes/Landing";
|
||||
import Login from "@app/routes/Login";
|
||||
import Signup from "@app/routes/Signup";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import InviteAccept from "@app/routes/InviteAccept";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
|
||||
// Import global styles
|
||||
@@ -26,6 +27,7 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
|
||||
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
|
||||
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
|
||||
|
||||
/**
|
||||
* Proprietary extension of createConfigNavSections that adds workspace sections
|
||||
*/
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false
|
||||
): ConfigNavSection[] => {
|
||||
// Get the core sections
|
||||
const sections = createCoreConfigNavSections(isAdmin, runningEE);
|
||||
|
||||
// Add Workspace section if user is admin
|
||||
if (isAdmin) {
|
||||
const workspaceSection: ConfigNavSection = {
|
||||
title: 'Workspace',
|
||||
items: [
|
||||
{
|
||||
key: 'people',
|
||||
label: 'People',
|
||||
icon: 'group-rounded',
|
||||
component: <PeopleSection />
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
label: 'Teams',
|
||||
icon: 'groups-rounded',
|
||||
component: <TeamsSection />
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Insert workspace section after Preferences (at index 1)
|
||||
sections.splice(1, 0, workspaceSection);
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from '@core/components/shared/config/configNavSections';
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
|
||||
|
||||
/**
|
||||
* Proprietary extension of GeneralSection that adds account management
|
||||
*/
|
||||
const GeneralSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { signOut, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.description', 'Configure general application preferences.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<Stack gap="xs" align="flex-end">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.user', 'User')}: <strong>{user.email || user.username}</strong>
|
||||
</Text>
|
||||
<Button color="red" variant="outline" size="xs" onClick={handleLogout}>
|
||||
{t('settings.general.logout', 'Log out')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Render core general section preferences (without title since we show it above) */}
|
||||
<CoreGeneralSection hideTitle />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSection;
|
||||
+382
-81
@@ -19,6 +19,8 @@ import {
|
||||
SegmentedControl,
|
||||
Tooltip,
|
||||
CloseButton,
|
||||
Avatar,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
@@ -38,7 +40,18 @@ export default function PeopleSection() {
|
||||
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct');
|
||||
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
|
||||
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
maxAllowedUsers: number;
|
||||
availableSlots: number;
|
||||
grandfatheredUserCount: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
totalUsers: number;
|
||||
} | null>(null);
|
||||
|
||||
// Form state for direct invite
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
@@ -56,6 +69,15 @@ export default function PeopleSection() {
|
||||
teamId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
// Form state for invite link
|
||||
const [inviteLinkForm, setInviteLinkForm] = useState({
|
||||
email: '',
|
||||
role: 'ROLE_USER',
|
||||
teamId: undefined as number | undefined,
|
||||
expiryHours: 72,
|
||||
sendEmail: false,
|
||||
});
|
||||
|
||||
// Form state for edit user modal
|
||||
const [editForm, setEditForm] = useState({
|
||||
role: 'ROLE_USER',
|
||||
@@ -89,6 +111,16 @@ export default function PeopleSection() {
|
||||
|
||||
setUsers(enrichedUsers);
|
||||
setTeams(teamsData);
|
||||
|
||||
// Store license information
|
||||
setLicenseInfo({
|
||||
maxAllowedUsers: adminData.maxAllowedUsers,
|
||||
availableSlots: adminData.availableSlots,
|
||||
grandfatheredUserCount: adminData.grandfatheredUserCount,
|
||||
licenseMaxUsers: adminData.licenseMaxUsers,
|
||||
premiumEnabled: adminData.premiumEnabled,
|
||||
totalUsers: adminData.totalUsers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch people data:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load people data' });
|
||||
@@ -189,6 +221,51 @@ export default function PeopleSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateInviteLink = async () => {
|
||||
try {
|
||||
setProcessing(true);
|
||||
const response = await userManagementService.generateInviteLink({
|
||||
email: inviteLinkForm.email,
|
||||
role: inviteLinkForm.role,
|
||||
teamId: inviteLinkForm.teamId,
|
||||
expiryHours: inviteLinkForm.expiryHours,
|
||||
sendEmail: inviteLinkForm.sendEmail,
|
||||
});
|
||||
|
||||
// Construct full frontend URL
|
||||
const frontendUrl = `${window.location.origin}/invite/${response.token}`;
|
||||
setGeneratedInviteLink(frontendUrl);
|
||||
|
||||
if (response.emailSent) {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and email sent!')
|
||||
});
|
||||
} else if (inviteLinkForm.sendEmail && response.emailError) {
|
||||
// Email was requested but failed
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('workspace.people.inviteLink.emailFailed', 'Invite link generated, but email failed'),
|
||||
body: t('workspace.people.inviteLink.emailFailedDetails', 'Error: {0}. Please share the invite link manually.').replace('{0}', response.emailError)
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('workspace.people.inviteLink.success', 'Invite link generated successfully!')
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate invite link:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.inviteLink.error', 'Failed to generate invite link');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateUserRole = async () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
@@ -267,6 +344,18 @@ export default function PeopleSection() {
|
||||
});
|
||||
};
|
||||
|
||||
const closeInviteModal = () => {
|
||||
setInviteModalOpened(false);
|
||||
setGeneratedInviteLink(null);
|
||||
setInviteLinkForm({
|
||||
email: '',
|
||||
role: 'ROLE_USER',
|
||||
teamId: undefined,
|
||||
expiryHours: 72,
|
||||
sendEmail: false,
|
||||
});
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter((user) =>
|
||||
user.username.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
@@ -289,12 +378,12 @@ export default function PeopleSection() {
|
||||
const renderRoleOption = ({ option }: { option: any }) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<LocalIcon icon={option.icon} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>{option.label}</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -325,6 +414,39 @@ export default function PeopleSection() {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* License Information - Compact */}
|
||||
{licenseInfo && (
|
||||
<Group gap="md" c="dimmed" style={{ fontSize: '0.875rem' }}>
|
||||
<Text size="sm" span>
|
||||
<Text component="span" fw={600} c="inherit">{licenseInfo.totalUsers}</Text>
|
||||
<Text component="span" c="dimmed"> / </Text>
|
||||
<Text component="span" fw={600} c="inherit">{licenseInfo.maxAllowedUsers}</Text>
|
||||
<Text component="span" c="dimmed" ml={4}>{t('workspace.people.license.users', 'users')}</Text>
|
||||
</Text>
|
||||
|
||||
{licenseInfo.availableSlots === 0 && (
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{licenseInfo.grandfatheredUserCount > 0 && (
|
||||
<Text size="sm" c="dimmed" span>
|
||||
•
|
||||
<Text component="span" ml={4}>
|
||||
{t('workspace.people.license.grandfatheredShort', '{{count}} grandfathered', { count: licenseInfo.grandfatheredUserCount })}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && (
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Header Actions */}
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
@@ -334,77 +456,138 @@ export default function PeopleSection() {
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ maxWidth: 300 }}
|
||||
/>
|
||||
<Button leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />} onClick={() => setInviteModalOpened(true)}>
|
||||
{t('workspace.people.addMembers')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
|
||||
position="bottom"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setInviteModalOpened(true)}
|
||||
disabled={licenseInfo ? licenseInfo.availableSlots === 0 : false}
|
||||
>
|
||||
{t('workspace.people.addMembers')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Members Table */}
|
||||
<Paper withBorder p="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.user')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
|
||||
{t('workspace.people.role')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.team')}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('workspace.people.user')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.role')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.team')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.status')}</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.people.noMembersFound')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.people.noMembersFound')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{user.isActive && (
|
||||
<div
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
!user.enabled
|
||||
? t('workspace.people.disabled', 'Disabled')
|
||||
: user.isActive
|
||||
? t('workspace.people.activeSession', 'Active session')
|
||||
: t('workspace.people.active', 'Active')
|
||||
}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
color={user.enabled ? 'blue' : 'gray'}
|
||||
styles={{
|
||||
root: {
|
||||
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
|
||||
opacity: user.enabled ? 1 : 0.5,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-green-6)',
|
||||
flexShrink: 0,
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={t('workspace.people.activeSession', 'Active session')}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
variant="light"
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN') ? t('workspace.people.admin') : t('workspace.people.member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{user.team?.name || '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={user.enabled ? 'green' : 'red'} variant="light">
|
||||
{user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Tooltip>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin', 'Admin')
|
||||
: t('workspace.people.member', 'Member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{user.team?.name ? (
|
||||
<Tooltip label={user.team.name} disabled={user.team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
maw={150}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.team.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">—</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
@@ -456,28 +639,27 @@ export default function PeopleSection() {
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={inviteModalOpened}
|
||||
onClose={() => setInviteModalOpened(false)}
|
||||
onClose={closeInviteModal}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Box pos="relative">
|
||||
<CloseButton
|
||||
onClick={() => setInviteModalOpened(false)}
|
||||
onClick={closeInviteModal}
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
@@ -495,6 +677,32 @@ export default function PeopleSection() {
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* License Warning/Info */}
|
||||
{licenseInfo && (
|
||||
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
|
||||
<Text size="sm" fw={500}>
|
||||
{licenseInfo.availableSlots > 0
|
||||
? t('workspace.people.license.slotsAvailable', {
|
||||
count: licenseInfo.availableSlots,
|
||||
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
|
||||
})
|
||||
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('workspace.people.license.currentUsage', {
|
||||
current: licenseInfo.totalUsers,
|
||||
max: licenseInfo.maxAllowedUsers,
|
||||
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<Tooltip
|
||||
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
|
||||
@@ -506,12 +714,19 @@ export default function PeopleSection() {
|
||||
<div>
|
||||
<SegmentedControl
|
||||
value={inviteMode}
|
||||
onChange={(value) => setInviteMode(value as 'email' | 'direct')}
|
||||
onChange={(value) => {
|
||||
setInviteMode(value as 'email' | 'direct' | 'link');
|
||||
setGeneratedInviteLink(null);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
label: t('workspace.people.inviteMode.username', 'Username'),
|
||||
value: 'direct',
|
||||
},
|
||||
{
|
||||
label: t('workspace.people.inviteMode.link', 'Link'),
|
||||
value: 'link',
|
||||
},
|
||||
{
|
||||
label: t('workspace.people.inviteMode.email', 'Email'),
|
||||
value: 'email',
|
||||
@@ -523,6 +738,90 @@ export default function PeopleSection() {
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Link Mode */}
|
||||
{inviteMode === 'link' && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
|
||||
placeholder={t('workspace.people.inviteLink.emailPlaceholder', '[email protected]')}
|
||||
value={inviteLinkForm.email}
|
||||
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
|
||||
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
|
||||
/>
|
||||
<Select
|
||||
label={t('workspace.people.addMember.role')}
|
||||
data={roleOptions}
|
||||
value={inviteLinkForm.role}
|
||||
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
|
||||
renderOption={renderRoleOption}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Select
|
||||
label={t('workspace.people.addMember.team')}
|
||||
placeholder={t('workspace.people.addMember.teamPlaceholder')}
|
||||
data={teamOptions}
|
||||
value={inviteLinkForm.teamId?.toString()}
|
||||
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
|
||||
clearable
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
|
||||
type="number"
|
||||
value={inviteLinkForm.expiryHours}
|
||||
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
|
||||
min={1}
|
||||
max={720}
|
||||
/>
|
||||
{inviteLinkForm.email && (
|
||||
<Checkbox
|
||||
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
|
||||
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
|
||||
checked={inviteLinkForm.sendEmail}
|
||||
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display generated link */}
|
||||
{generatedInviteLink && (
|
||||
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
value={generatedInviteLink}
|
||||
readOnly
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedInviteLink);
|
||||
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
|
||||
} catch {
|
||||
// Fallback for browsers without clipboard API
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = generatedInviteLink;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email Mode */}
|
||||
{inviteMode === 'email' && config?.enableEmailInvites && (
|
||||
<>
|
||||
@@ -599,7 +898,7 @@ export default function PeopleSection() {
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
onClick={inviteMode === 'email' ? handleEmailInvite : handleInviteUser}
|
||||
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
@@ -607,10 +906,12 @@ export default function PeopleSection() {
|
||||
>
|
||||
{inviteMode === 'email'
|
||||
? t('workspace.people.emailInvite.submit', 'Send Invites')
|
||||
: t('workspace.people.addMember.submit')}
|
||||
: inviteMode === 'link'
|
||||
? t('workspace.people.inviteLink.submit', 'Generate Link')
|
||||
: t('workspace.people.addMember.submit')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
@@ -623,14 +924,14 @@ export default function PeopleSection() {
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Box pos="relative">
|
||||
<CloseButton
|
||||
onClick={closeEditModal}
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
@@ -666,7 +967,7 @@ export default function PeopleSection() {
|
||||
{t('workspace.people.editMember.submit')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Box>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
+124
-55
@@ -11,10 +11,11 @@ import {
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Paper,
|
||||
CloseButton,
|
||||
Tooltip,
|
||||
Menu,
|
||||
Avatar,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
@@ -42,6 +43,11 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
availableSlots: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamDetails();
|
||||
fetchAllTeams();
|
||||
@@ -50,12 +56,20 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
const fetchTeamDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await teamService.getTeamDetails(teamId);
|
||||
const [data, adminData] = await Promise.all([
|
||||
teamService.getTeamDetails(teamId),
|
||||
userManagementService.getUsers(),
|
||||
]);
|
||||
console.log('[TeamDetailsSection] Raw data:', data);
|
||||
setTeam(data.team);
|
||||
setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
|
||||
setAvailableUsers(Array.isArray(data.availableUsers) ? data.availableUsers : []);
|
||||
setUserLastRequest(data.userLastRequest || {});
|
||||
|
||||
// Store license information
|
||||
setLicenseInfo({
|
||||
availableSlots: adminData.availableSlots,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch team details:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load team details' });
|
||||
@@ -227,65 +241,120 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
|
||||
{/* Add Member Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setAddMemberModalOpened(true)}
|
||||
disabled={team.name === 'Internal'}
|
||||
<Tooltip
|
||||
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
|
||||
position="bottom"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{t('workspace.teams.addMember')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setAddMemberModalOpened(true)}
|
||||
disabled={team.name === 'Internal' || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
|
||||
>
|
||||
{t('workspace.teams.addMember')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Members Table */}
|
||||
<Paper withBorder p="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('workspace.people.user')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.role')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.status')}</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.user')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
|
||||
{t('workspace.people.role')}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teamUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.teams.noMembers', 'No members in this team')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
teamUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{user.username}
|
||||
</Text>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
variant="light"
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin')
|
||||
: t('workspace.people.member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={user.enabled ? 'green' : 'red'} variant="light">
|
||||
{user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
teamUsers.map((user) => {
|
||||
const isActive = userLastRequest[user.username] &&
|
||||
(Date.now() - userLastRequest[user.username]) < 5 * 60 * 1000; // Active within last 5 minutes
|
||||
|
||||
return (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
!user.enabled
|
||||
? t('workspace.people.disabled', 'Disabled')
|
||||
: isActive
|
||||
? t('workspace.people.activeSession', 'Active session')
|
||||
: t('workspace.people.active', 'Active')
|
||||
}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
color={user.enabled ? 'blue' : 'gray'}
|
||||
styles={{
|
||||
root: {
|
||||
border: isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
|
||||
opacity: user.enabled ? 1 : 0.5,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
variant="light"
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin')
|
||||
: t('workspace.people.member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
@@ -352,11 +421,11 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Table>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
@@ -374,8 +443,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
@@ -433,8 +502,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
+45
-23
@@ -12,9 +12,9 @@ import {
|
||||
Loader,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
CloseButton,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
@@ -224,15 +224,26 @@ export default function TeamsSection() {
|
||||
</Group>
|
||||
|
||||
{/* Teams Table */}
|
||||
<Paper withBorder p="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('workspace.teams.teamName')}</Table.Th>
|
||||
<Table.Th>{t('workspace.teams.totalMembers')}</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
|
||||
{t('workspace.teams.teamName')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
|
||||
{t('workspace.teams.totalMembers')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teams.length === 0 ? (
|
||||
<Table.Tr>
|
||||
@@ -251,18 +262,30 @@ export default function TeamsSection() {
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{team.name}
|
||||
</Text>
|
||||
<Tooltip label={team.name} disabled={team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
c="dark"
|
||||
maw={200}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{team.name === 'Internal' && (
|
||||
<Badge size="xs" color="gray">
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{t('workspace.teams.system')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{team.userCount || 0}</Text>
|
||||
<Text size="sm" c="dimmed">{team.userCount || 0}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
@@ -297,8 +320,7 @@ export default function TeamsSection() {
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Table>
|
||||
|
||||
{/* Create Team Modal */}
|
||||
<Modal
|
||||
@@ -316,8 +338,8 @@ export default function TeamsSection() {
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
@@ -361,8 +383,8 @@ export default function TeamsSection() {
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
@@ -409,8 +431,8 @@ export default function TeamsSection() {
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, Paper, Center, Loader, TextInput, PasswordInput, Anchor } from '@mantine/core';
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface InviteData {
|
||||
email: string | null;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
emailRequired: boolean;
|
||||
}
|
||||
|
||||
export default function InviteAccept() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [inviteData, setInviteData] = useState<InviteData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
|
||||
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogTitle: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
|
||||
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t('invite.invalidToken', 'Invalid invitation link'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
validateToken();
|
||||
}, [token]);
|
||||
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get<InviteData>(`/api/v1/invite/validate/${token}`, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
setInviteData(response.data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
err.message ||
|
||||
t('invite.validationError', 'Failed to validate invitation link');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate email if required
|
||||
if (inviteData?.emailRequired) {
|
||||
if (!email || email.trim().length === 0) {
|
||||
setError(t('invite.emailRequired', 'Email address is required'));
|
||||
return;
|
||||
}
|
||||
if (!email.includes('@')) {
|
||||
setError(t('invite.invalidEmail', 'Invalid email address'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate passwords
|
||||
if (!password) {
|
||||
setError(t('invite.passwordRequired', 'Password is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('invite.passwordMismatch', 'Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
if (inviteData?.emailRequired) {
|
||||
formData.append('email', email.trim().toLowerCase());
|
||||
}
|
||||
formData.append('password', password);
|
||||
|
||||
await apiClient.post(`/api/v1/invite/accept/${token}`, formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
|
||||
// Success - redirect to login
|
||||
navigate('/login?messageType=accountCreated');
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
err.message ||
|
||||
t('invite.acceptError', 'Failed to create account');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('invite.validating', 'Validating invitation...')} />
|
||||
<Center py="xl">
|
||||
<Loader size="md" />
|
||||
</Center>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !inviteData) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('invite.invalidInvitation', 'Invalid Invitation')} />
|
||||
<ErrorMessage error={error} />
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
|
||||
>
|
||||
{t('invite.goToLogin', 'Go to Login')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={t('invite.welcomeTitle', "You've been invited!")}
|
||||
subtitle={t('invite.welcomeSubtitle', 'Complete your account setup to get started')}
|
||||
/>
|
||||
|
||||
{inviteData && !inviteData.emailRequired && (
|
||||
<Paper withBorder p="md" mb="lg" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-3)' }}>
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="xs" tt="uppercase" c="dimmed" fw={500} style={{ letterSpacing: '0.05em' }}>
|
||||
{t('invite.accountFor', 'Creating account for')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{inviteData.email}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<form onSubmit={handleAccept}>
|
||||
<Stack gap="md">
|
||||
{inviteData?.emailRequired && (
|
||||
<TextInput
|
||||
label={t('invite.email', 'Email address')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('invite.emailPlaceholder', 'Enter your email address')}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
label={t('invite.choosePassword', 'Choose a password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('invite.passwordPlaceholder', 'Enter your password')}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('invite.confirmPassword', 'Confirm password')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('invite.confirmPasswordPlaceholder', 'Re-enter your password')}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{submitting ? t('invite.creating', 'Creating Account...') : t('invite.createAccount', 'Create Account')}
|
||||
</button>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Center mt="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('invite.alreadyHaveAccount', 'Already have an account?')}{' '}
|
||||
<Anchor component="button" type="button" onClick={() => navigate('/login')} c="dark">
|
||||
{t('invite.signIn', 'Sign in')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Center>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -17,26 +17,42 @@ import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { session, loading } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Prefill email from query param (e.g. after password reset)
|
||||
// Handle query params (email prefill and success messages)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const emailFromQuery = url.searchParams.get('email');
|
||||
const emailFromQuery = searchParams.get('email');
|
||||
if (emailFromQuery) {
|
||||
setEmail(emailFromQuery);
|
||||
}
|
||||
|
||||
const messageType = searchParams.get('messageType')
|
||||
if (messageType) {
|
||||
switch (messageType) {
|
||||
case 'accountCreated':
|
||||
setSuccessMessage(t('login.accountCreatedSuccess', 'Account created successfully! You can now sign in.'))
|
||||
break
|
||||
case 'passwordChanged':
|
||||
setSuccessMessage(t('login.passwordChangedSuccess', 'Password changed successfully! Please sign in with your new password.'))
|
||||
break
|
||||
case 'credsUpdated':
|
||||
setSuccessMessage(t('login.credentialsUpdated', 'Your credentials have been updated. Please sign in again.'))
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
}, [searchParams, t]);
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
@@ -121,6 +137,22 @@ export default function Login() {
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('login.login') || 'Sign in'} />
|
||||
|
||||
{/* Success message */}
|
||||
{successMessage && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#16a34a'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', textAlign: 'center' }}>
|
||||
{successMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
|
||||
@@ -22,6 +22,7 @@ export default defineConfig(({ mode }) => {
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
host: true,
|
||||
// make sure this port matches the devUrl port in tauri.conf.json file
|
||||
port: 5173,
|
||||
// Tauri expects a fixed port, fail if that port is not available
|
||||
|
||||
Reference in New Issue
Block a user