refactor: remove legacy Thymeleaf web UI controllers and templates (#5406)

# Description of Changes

This pull request completes the removal of Thymeleaf template engine
support and documentation from the Stirling-PDF codebase and developer
guides, reflecting the project's full migration to a React-based
frontend. It removes all references to Thymeleaf in code, configuration,
and documentation, and updates guides to focus exclusively on the React
SPA architecture. This streamlines the codebase and clarifies the
development workflow for contributors.

**Codebase cleanup: Thymeleaf removal**
- Removed all commented-out Thymeleaf dependencies and related
configuration beans from `build.gradle` and `AppConfig.java`
(`app/common/build.gradle`,
`app/common/src/main/java/stirling/software/common/configuration/AppConfig.java`)
[[1]](diffhunk://#diff-2a1a21726f33b05d16451237c68d6df91a5f4a58419d839715f3f1538a9a14aeL32)
[[2]](diffhunk://#diff-70792df9a0ab5675ded888c9eb8e2815c780d7b39f4bda8cf2da51d1b336899aL67-L76).
- Fully commented out (as a precursor to future deletion) the
`FileFallbackTemplateResolver` and `InputStreamTemplateResource`
classes, which were only used for Thymeleaf template resolution
(`app/common/src/main/java/stirling/software/common/configuration/FileFallbackTemplateResolver.java`,
`app/common/src/main/java/stirling/software/common/model/InputStreamTemplateResource.java`)
[[1]](diffhunk://#diff-e2bc7614074316b972355cb7dda47b98f75b00eb6b2ca4f143a680ab2803dcd8L1-L49)
[[2]](diffhunk://#diff-ab10ee12d8de8fb77759e931170373d388bde04bad6d0e42a0ab674355ef7ef3L1-L40).

**Documentation updates: React-only focus**
- Removed all instructions and references to migrating or developing
with Thymeleaf templates in `DeveloperGuide.md` and `ADDING_TOOLS.md`,
including detailed Thymeleaf usage examples, migration steps, and
translation key usage in templates
[[1]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL5-R5)
[[2]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL41-L43)
[[3]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL103-L105)
[[4]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL157)
[[5]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL312)
[[6]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL404-R396)
[[7]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL451-L505)
[[8]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL530-R467)
[[9]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL585-L669)
[[10]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL699-L709)
[[11]](diffhunk://#diff-e2f8148ea620602b7761e8ee24afeac1c577476630528e210fe0b22e950016ddL3-R3)
[[12]](diffhunk://#diff-e2f8148ea620602b7761e8ee24afeac1c577476630528e210fe0b22e950016ddL267-R267).
- Updated architecture descriptions in `CLAUDE.md` to reflect that the
frontend is now exclusively a React SPA and that Thymeleaf templates
have been fully replaced
[[1]](diffhunk://#diff-6ebdb617a8104a7756d0cf36578ab01103dc9f07e4dc6feb751296b9c402faf7L131-R132)
[[2]](diffhunk://#diff-6ebdb617a8104a7756d0cf36578ab01103dc9f07e4dc6feb751296b9c402faf7L143-L144).

**Labeler configuration update**
- Removed labeler rules for files related to the old Thymeleaf-based web
controllers and UI directories, as these are now obsolete
(`.github/labeler-config-srvaroa.yml`).

These changes ensure the codebase and documentation are consistent with
the new React-only frontend approach, reducing maintenance overhead and
potential confusion for contributors.

---

## 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)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### 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.
This commit is contained in:
Ludy
2026-01-21 22:58:29 +01:00
committed by GitHub
parent b58efaf388
commit 80cba55459
135 changed files with 11 additions and 18244 deletions

View File

@@ -1,351 +0,0 @@
package stirling.software.proprietary.controller;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** Controller for the audit dashboard. Admin-only access. */
@Slf4j
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequestMapping("/audit")
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardController {
private final PersistentAuditEventRepository auditRepository;
private final AuditConfigurationProperties auditConfig;
private final ObjectMapper objectMapper;
/** Display the audit dashboard. */
@GetMapping
public String showDashboard(Model model) {
model.addAttribute("auditEnabled", auditConfig.isEnabled());
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
model.addAttribute("auditLevelInt", auditConfig.getLevel());
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
// Add audit level enum values for display
model.addAttribute("auditLevels", AuditLevel.values());
// Add audit event types for the dropdown
model.addAttribute("auditEventTypes", AuditEventType.values());
return "audit/dashboard";
}
/** Get audit events data for the dashboard tables. */
@GetMapping("/data")
@ResponseBody
public Map<String, Object> getAuditData(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "principal", required = false) String principal,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate endDate,
HttpServletRequest request) {
Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending());
Page<PersistentAuditEvent> events;
String mode;
if (type != null && principal != null && startDate != null && endDate != null) {
mode = "principal + type + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
principal, type, start, end, pageable);
} else if (type != null && principal != null) {
mode = "principal + type";
events = auditRepository.findByPrincipalAndType(principal, type, pageable);
} else if (type != null && startDate != null && endDate != null) {
mode = "type + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable);
} else if (principal != null && startDate != null && endDate != null) {
mode = "principal + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findByPrincipalAndTimestampBetween(
principal, start, end, pageable);
} else if (startDate != null && endDate != null) {
mode = "startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTimestampBetween(start, end, pageable);
} else if (type != null) {
mode = "type";
events = auditRepository.findByType(type, pageable);
} else if (principal != null) {
mode = "principal";
events = auditRepository.findByPrincipal(principal, pageable);
} else {
mode = "all";
events = auditRepository.findAll(pageable);
}
// Logging
List<PersistentAuditEvent> content = events.getContent();
Map<String, Object> response = new HashMap<>();
response.put("content", content);
response.put("totalPages", events.getTotalPages());
response.put("totalElements", events.getTotalElements());
response.put("currentPage", events.getNumber());
return response;
}
/** Get statistics for charts. */
@GetMapping("/stats")
@ResponseBody
public Map<String, Object> getAuditStats(
@RequestParam(value = "days", defaultValue = "7") int days) {
// Get events from the last X days
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
Map<String, Long> eventsByPrincipal =
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()));
Map<String, Object> stats = new HashMap<>();
stats.put("eventsByType", eventsByType);
stats.put("eventsByPrincipal", eventsByPrincipal);
stats.put("eventsByDay", eventsByDay);
stats.put("totalEvents", events.size());
return stats;
}
/** Get all unique event types from the database for filtering. */
@GetMapping("/types")
@ResponseBody
public List<String> getAuditTypes() {
// 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);
return combinedTypes.stream().sorted().collect(Collectors.toList());
}
/** Export audit data as CSV. */
@GetMapping("/export")
public ResponseEntity<byte[]> exportAuditData(
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "principal", required = false) String principal,
@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 getAuditData
List<PersistentAuditEvent> events;
if (type != null && principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
principal, type, start, end);
} else if (type != null && principal != null) {
events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type);
} else if (type != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end);
} else if (principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
principal, 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 (type != null) {
events = auditRepository.findByTypeForExport(type);
} else if (principal != null) {
events = auditRepository.findAllByPrincipalForExport(principal);
} else {
events = auditRepository.findAll();
}
// Convert to CSV
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();
// Set up HTTP headers for download
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDispositionFormData("attachment", "audit_export.csv");
return ResponseEntity.ok().headers(headers).body(csvBytes);
}
/** Export audit data as JSON. */
@GetMapping("/export/json")
public ResponseEntity<byte[]> exportAuditDataJson(
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "principal", required = false) String principal,
@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 getAuditData
List<PersistentAuditEvent> events;
if (type != null && principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
principal, type, start, end);
} else if (type != null && principal != null) {
events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type);
} else if (type != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end);
} else if (principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
principal, 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 (type != null) {
events = auditRepository.findByTypeForExport(type);
} else if (principal != null) {
events = auditRepository.findAllByPrincipalForExport(principal);
} else {
events = auditRepository.findAll();
}
// Convert to JSON
try {
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
// Set up HTTP headers for download
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();
}
}
/** Helper method to escape CSV fields. */
private String escapeCSV(String field) {
if (field == null) {
return "";
}
// Replace double quotes with two double quotes and wrap in quotes
return "\"" + field.replace("\"", "\"\"") + "\"";
}
}

View File

@@ -1,41 +0,0 @@
package stirling.software.proprietary.controller.web;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@Controller
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardWebController {
private final AuditConfigurationProperties auditConfig;
/** Display the audit dashboard. */
@GetMapping("/audit")
@Hidden
public String showDashboard(Model model) {
model.addAttribute("auditEnabled", auditConfig.isEnabled());
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
model.addAttribute("auditLevelInt", auditConfig.getLevel());
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
// Add audit level enum values for display
model.addAttribute("auditLevels", AuditLevel.values());
// Add audit event types for the dropdown
model.addAttribute("auditEventTypes", AuditEventType.values());
return "audit/dashboard";
}
}

View File

@@ -1,478 +0,0 @@
package stirling.software.proprietary.security.config;
import static stirling.software.common.util.ProviderUtils.validateProvider;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.ui.Model;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2.Client;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@Slf4j
@Tag(name = "Account Security", description = "Account Security APIs")
public class AccountWebController {
public static final String OAUTH_2_AUTHORIZATION = "/oauth2/authorization/";
private final ApplicationProperties applicationProperties;
private final SessionPersistentRegistry sessionPersistentRegistry;
// Assuming you have a repository for user operations
private final UserRepository userRepository;
private final TeamRepository teamRepository;
public AccountWebController(
ApplicationProperties applicationProperties,
SessionPersistentRegistry sessionPersistentRegistry,
UserRepository userRepository,
TeamRepository teamRepository) {
this.applicationProperties = applicationProperties;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.userRepository = userRepository;
this.teamRepository = teamRepository;
}
// @GetMapping("/login")
public String login(HttpServletRequest request, Model model, Authentication authentication) {
// If the user is already authenticated and it's not a logout scenario, redirect them to the
// home page.
if (authentication != null
&& authentication.isAuthenticated()
&& request.getParameter("logout") == null) {
return "redirect:/";
}
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null) {
if (oauth.getEnabled()) {
if (oauth.isSettingsValid()) {
String firstChar = String.valueOf(oauth.getProvider().charAt(0));
String clientName =
oauth.getProvider().replaceFirst(firstChar, firstChar.toUpperCase());
providerList.put(OAUTH_2_AUTHORIZATION + oauth.getProvider(), clientName);
}
Client client = oauth.getClient();
if (client != null) {
GoogleProvider google = client.getGoogle();
if (validateProvider(google)) {
providerList.put(
OAUTH_2_AUTHORIZATION + google.getName(), google.getClientName());
}
GitHubProvider github = client.getGithub();
if (validateProvider(github)) {
providerList.put(
OAUTH_2_AUTHORIZATION + github.getName(), github.getClientName());
}
KeycloakProvider keycloak = client.getKeycloak();
if (validateProvider(keycloak)) {
providerList.put(
OAUTH_2_AUTHORIZATION + keycloak.getName(),
keycloak.getClientName());
}
}
}
}
SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
if (applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
return "redirect:" + request.getRequestURL() + saml2AuthenticationPath;
} else {
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
}
}
// Remove any null keys/values from the providerList
providerList
.entrySet()
.removeIf(entry -> entry.getKey() == null || entry.getValue() == null);
model.addAttribute("providerList", providerList);
model.addAttribute("loginMethod", securityProps.getLoginMethod());
boolean altLogin = !providerList.isEmpty() ? securityProps.isAltLogin() : false;
model.addAttribute("altLogin", altLogin);
model.addAttribute("currentPage", "login");
String error = request.getParameter("error");
if (error != null) {
switch (error) {
case "badCredentials" -> error = "login.invalid";
case "locked" -> error = "login.locked";
case "oauth2AuthenticationError" -> error = "userAlreadyExistsOAuthMessage";
}
model.addAttribute("error", error);
}
String errorOAuth = request.getParameter("errorOAuth");
if (errorOAuth != null) {
switch (errorOAuth) {
case "oAuth2AutoCreateDisabled" -> errorOAuth = "login.oAuth2AutoCreateDisabled";
case "invalidUsername" -> errorOAuth = "login.invalid";
case "userAlreadyExistsWeb" -> errorOAuth = "userAlreadyExistsWebMessage";
case "oAuth2AuthenticationErrorWeb" -> errorOAuth = "login.oauth2InvalidUserType";
case "invalid_token_response" -> errorOAuth = "login.oauth2InvalidTokenResponse";
case "authorization_request_not_found" ->
errorOAuth = "login.oauth2RequestNotFound";
case "access_denied" -> errorOAuth = "login.oauth2AccessDenied";
case "invalid_user_info_response" ->
errorOAuth = "login.oauth2InvalidUserInfoResponse";
case "invalid_request" -> errorOAuth = "login.oauth2invalidRequest";
case "invalid_id_token" -> errorOAuth = "login.oauth2InvalidIdToken";
case "oAuth2AdminBlockedUser" -> errorOAuth = "login.oAuth2AdminBlockedUser";
case "userIsDisabled" -> errorOAuth = "login.userIsDisabled";
case "invalid_destination" -> errorOAuth = "login.invalid_destination";
case "relying_party_registration_not_found" ->
errorOAuth = "login.relyingPartyRegistrationNotFound";
// Valid InResponseTo was not available from the validation context, unable to
// evaluate
case "invalid_in_response_to" -> errorOAuth = "login.invalidInResponseTo";
case "not_authentication_provider_found" ->
errorOAuth = "login.not_authentication_provider_found";
}
model.addAttribute("errorOAuth", errorOAuth);
}
if (request.getParameter("messageType") != null) {
model.addAttribute("messageType", "changedCredsMessage");
}
if (request.getParameter("logout") != null) {
model.addAttribute("logoutMessage", "login.logoutMessage");
}
return "login";
}
// @EnterpriseEndpoint
// @PreAuthorize("hasRole('ROLE_ADMIN')")
// @GetMapping("/usage")
public String showUsage() {
return "usage";
}
// @PreAuthorize("hasRole('ROLE_ADMIN')")
// @GetMapping("/adminSettings")
public String showAddUserForm(
HttpServletRequest request, Model model, Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
// Map to store session information and user activity status
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Instant> userLastRequest = new HashMap<>();
int activeUsers = 0;
int disabledUsers = 0;
while (iterator.hasNext()) {
User user = iterator.next();
if (user != null) {
boolean shouldRemove = false;
// Check if user is an INTERNAL_API_USER
for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
shouldRemove = true;
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
break;
}
}
// Also check if user is part of the Internal team
if (user.getTeam() != null
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
shouldRemove = true;
}
// Remove the user if either condition is true
if (shouldRemove) {
iterator.remove();
continue;
}
// Determine the user's session status and last request time
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
boolean hasActiveSession = false;
Instant lastRequest = null;
Optional<SessionEntity> latestSession =
sessionPersistentRegistry.findLatestSession(user.getUsername());
if (latestSession.isPresent()) {
SessionEntity sessionEntity = latestSession.get();
// sessionEntity stores Instant directly
Instant lastAccessedTime =
Optional.ofNullable(sessionEntity.getLastRequest())
.orElse(Instant.EPOCH);
Instant now = Instant.now();
// Calculate session expiration and update session status accordingly
Instant expirationTime =
lastAccessedTime.plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId());
} else {
hasActiveSession = !sessionEntity.isExpired();
}
lastRequest = lastAccessedTime;
} else {
// No session, set default last request time
lastRequest = Instant.EPOCH;
}
userSessions.put(user.getUsername(), hasActiveSession);
userLastRequest.put(user.getUsername(), lastRequest);
if (hasActiveSession) {
activeUsers++;
}
if (!user.isEnabled()) {
disabledUsers++;
}
}
}
// Sort users by active status and last request date
List<User> sortedUsers =
allUsers.stream()
.sorted(
(u1, u2) -> {
boolean u1Active =
userSessions.getOrDefault(u1.getUsername(), false);
boolean u2Active =
userSessions.getOrDefault(u2.getUsername(), false);
if (u1Active && !u2Active) {
return -1;
} else if (!u1Active && u2Active) {
return 1;
} else {
Instant u1LastRequest =
userLastRequest.getOrDefault(
u1.getUsername(), Instant.EPOCH);
Instant u2LastRequest =
userLastRequest.getOrDefault(
u2.getUsername(), Instant.EPOCH);
return u2LastRequest.compareTo(u1LastRequest);
}
})
.toList();
String messageType = request.getParameter("messageType");
String deleteMessage;
if (messageType != null) {
deleteMessage =
switch (messageType) {
case "deleteCurrentUser" -> "deleteCurrentUserMessage";
case "deleteUsernameExists" -> "deleteUsernameExistsMessage";
default -> null;
};
model.addAttribute("deleteMessage", deleteMessage);
String addMessage;
addMessage =
switch (messageType) {
case "usernameExists" -> "usernameExistsMessage";
case "invalidUsername" -> "invalidUsernameMessage";
case "invalidPassword" -> "invalidPasswordMessage";
default -> null;
};
model.addAttribute("addMessage", addMessage);
}
String changeMessage;
if (messageType != null) {
changeMessage =
switch (messageType) {
case "userNotFound" -> "userNotFoundMessage";
case "downgradeCurrentUser" -> "downgradeCurrentUserMessage";
case "disabledCurrentUser" -> "disabledCurrentUserMessage";
case "cannotMoveInternalUsers" -> "team.cannotMoveInternalUsers";
case "internalTeamNotAccessible" -> "team.internalTeamNotAccessible";
case "invalidRole" -> "invalidRoleMessage";
default -> messageType;
};
model.addAttribute("changeMessage", changeMessage);
}
model.addAttribute("users", sortedUsers);
model.addAttribute("currentUsername", authentication.getName());
model.addAttribute("roleDetails", roleDetails);
model.addAttribute("userSessions", userSessions);
model.addAttribute("userLastRequest", userLastRequest);
model.addAttribute("totalUsers", allUsers.size());
model.addAttribute("activeUsers", activeUsers);
model.addAttribute("disabledUsers", disabledUsers);
// Get all teams but filter out the Internal team
List<Team> allTeams =
teamRepository.findAll().stream()
.filter(
team ->
!stirling.software.proprietary.security.service.TeamService
.INTERNAL_TEAM_NAME
.equals(team.getName()))
.toList();
model.addAttribute("teams", allTeams);
model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers());
return "adminSettings";
}
// @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
// @GetMapping("/account")
public String account(HttpServletRequest request, Model model, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return "redirect:/";
}
if (authentication.isAuthenticated()) {
Object principal = authentication.getPrincipal();
String username = null;
// Retrieve username and other attributes and add login attributes to the model
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
model.addAttribute("oAuth2Login", false);
}
if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
model.addAttribute("oAuth2Login", true);
}
if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
username = saml2User.name();
model.addAttribute("saml2Login", true);
}
if (username != null) {
// Fetch user details from the database
Optional<User> user = userRepository.findByUsernameIgnoreCaseWithSettings(username);
if (user.isEmpty()) {
return "redirect:/error";
}
// Convert settings map to JSON string
ObjectMapper objectMapper = new ObjectMapper();
String settingsJson;
try {
settingsJson = objectMapper.writeValueAsString(user.get().getSettings());
} catch (JsonProcessingException e) {
log.error("Error converting settings map", e);
return "redirect:/error";
}
String messageType = request.getParameter("messageType");
if (messageType != null) {
switch (messageType) {
case "notAuthenticated" -> messageType = "notAuthenticatedMessage";
case "userNotFound" -> messageType = "userNotFoundMessage";
case "incorrectPassword" -> messageType = "incorrectPasswordMessage";
case "usernameExists" -> messageType = "usernameExistsMessage";
case "invalidUsername" -> messageType = "invalidUsernameMessage";
}
}
model.addAttribute("username", username);
model.addAttribute("messageType", messageType);
model.addAttribute("role", user.get().getRolesAsString());
model.addAttribute("settings", settingsJson);
model.addAttribute("changeCredsFlag", user.get().isFirstLogin());
model.addAttribute("currentPage", "account");
}
} else {
return "redirect:/";
}
return "account";
}
// @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
// @GetMapping("/change-creds")
public String changeCreds(
HttpServletRequest request, Model model, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return "redirect:/";
}
if (authentication.isAuthenticated()) {
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails detailsUser) {
String username = detailsUser.getUsername();
// Fetch user details from the database
Optional<User> user = userRepository.findByUsernameIgnoreCase(username);
if (user.isEmpty()) {
// Handle error appropriately, example redirection in case of error
return "redirect:/error";
}
String messageType = request.getParameter("messageType");
if (messageType != null) {
switch (messageType) {
case "notAuthenticated":
messageType = "notAuthenticatedMessage";
break;
case "userNotFound":
messageType = "userNotFoundMessage";
break;
case "incorrectPassword":
messageType = "incorrectPasswordMessage";
break;
case "usernameExists":
messageType = "usernameExistsMessage";
break;
default:
break;
}
model.addAttribute("messageType", messageType);
}
model.addAttribute("username", username);
}
} else {
return "redirect:/";
}
return "change-creds";
}
}

View File

@@ -1,45 +0,0 @@
package stirling.software.proprietary.security.controller.web;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.ui.Model;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.service.DatabaseService;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@Tag(name = "Database Management", description = "Database management and security APIs")
@RequiredArgsConstructor
public class DatabaseWebController {
private final DatabaseService databaseService;
@Deprecated
@PreAuthorize("hasRole('ROLE_ADMIN')")
// @GetMapping("/database")
public String database(HttpServletRequest request, Model model, Authentication authentication) {
String error = request.getParameter("error");
String confirmed = request.getParameter("infoMessage");
if (error != null) {
model.addAttribute("error", error);
} else if (confirmed != null) {
model.addAttribute("infoMessage", confirmed);
}
List<FileInfo> backupList = databaseService.getBackupList();
model.addAttribute("backupFiles", backupList);
String dbVersion = databaseService.getH2Version();
model.addAttribute("databaseVersion", dbVersion);
if ("Unknown".equalsIgnoreCase(dbVersion)) {
model.addAttribute("infoMessage", "notSupported");
}
return "database";
}
}

View File

@@ -1,145 +0,0 @@
package stirling.software.proprietary.security.controller.web;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequestMapping("/teams")
@RequiredArgsConstructor
@Slf4j
public class TeamWebController {
private final TeamRepository teamRepository;
private final SessionRepository sessionRepository;
private final UserRepository userRepository;
@Deprecated
// @GetMapping
@PreAuthorize("hasRole('ROLE_ADMIN')")
public String listTeams(HttpServletRequest request, Model model) {
// Get teams with user counts using a DTO projection
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
// Filter out the Internal team
List<TeamWithUserCountDTO> teamsWithCounts =
allTeamsWithCounts.stream()
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
.toList();
// Get the latest activity for each team
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
// Convert the query results to a map for easy access in the view
Map<Long, Instant> teamLastRequest = new HashMap<>();
for (Object[] result : teamActivities) {
Long teamId = (Long) result[0]; // teamId alias
Instant lastActivity = (Instant) result[1]; // lastActivity alias
teamLastRequest.put(teamId, lastActivity);
}
String messageType = request.getParameter("messageType");
if (messageType != null) {
if ("teamCreated".equals(messageType)) {
model.addAttribute("addMessage", "teamCreated");
} else if ("teamExists".equals(messageType)) {
model.addAttribute("errorMessage", "teamExists");
} else if ("teamNotFound".equals(messageType)) {
model.addAttribute("errorMessage", "teamNotFound");
} else if ("teamNameExists".equals(messageType)) {
model.addAttribute("errorMessage", "teamNameExists");
} else if ("internalTeamNotAccessible".equals(messageType)) {
model.addAttribute("errorMessage", "team.internalTeamNotAccessible");
} else if ("teamRenamed".equals(messageType)) {
model.addAttribute("changeMessage", "teamRenamed");
} else if ("teamHasUsers".equals(messageType)) {
model.addAttribute("errorMessage", "teamHasUsers");
} else if ("teamDeleted".equals(messageType)) {
model.addAttribute("deleteMessage", "teamDeleted");
}
}
// Add data to the model
model.addAttribute("teamsWithCounts", teamsWithCounts);
model.addAttribute("teamLastRequest", teamLastRequest);
return "accounts/teams";
}
@Deprecated
// @GetMapping("/{id}")
@PreAuthorize("hasRole('ROLE_ADMIN')")
public String viewTeamDetails(
HttpServletRequest request, @PathVariable("id") Long id, Model model) {
// Get the team
Team team =
teamRepository
.findById(id)
.orElseThrow(() -> new RuntimeException("Team not found"));
// Prevent access to Internal team
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
return "redirect:/teams?error=internalTeamNotAccessible";
}
// Get users for this team directly using the direct query
List<User> teamUsers = userRepository.findAllByTeamId(id);
// Get all users not in this team for the Add User to Team dropdown
// Exclude users that are in the Internal team
List<User> allUsers = userRepository.findAllWithTeam();
List<User> availableUsers =
allUsers.stream()
.filter(
user ->
(user.getTeam() == null
|| !user.getTeam().getId().equals(id))
&& (user.getTeam() == null
|| !TeamService.INTERNAL_TEAM_NAME.equals(
user.getTeam().getName())))
.toList();
// Get the latest session for each user in the team
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
// Create a map of username to last request date
Map<String, Instant> userLastRequest = new HashMap<>();
for (Object[] result : userSessions) {
String username = (String) result[0]; // username alias
Instant lastRequest = (Instant) result[1]; // lastRequest alias
userLastRequest.put(username, lastRequest);
}
String errorMessage = request.getParameter("error");
if (errorMessage != null) {
if ("cannotMoveInternalUsers".equals(errorMessage)) {
model.addAttribute("errorMessage", "team.cannotMoveInternalUsers");
}
}
model.addAttribute("team", team);
model.addAttribute("teamUsers", teamUsers);
model.addAttribute("availableUsers", availableUsers);
model.addAttribute("userLastRequest", userLastRequest);
return "accounts/team-details";
}
}

View File

@@ -1,239 +0,0 @@
.dashboard-card {
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
border: 1px solid var(--md-sys-color-outline-variant);
}
.card-header {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.card-body {
background-color: var(--md-sys-color-surface-container);
}
.stat-card {
text-align: center;
padding: 20px;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
}
.stat-label {
font-size: 1rem;
color: var(--md-sys-color-on-surface-variant);
}
.chart-container {
position: relative;
height: 300px;
width: 100%;
}
.filter-card {
margin-bottom: 20px;
padding: 15px;
background-color: var(--md-sys-color-surface-container-low);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: 4px;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--md-sys-color-surface-container-high, rgba(229, 232, 241, 0.8));
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.level-indicator {
display: inline-block;
padding: 5px 10px;
border-radius: 15px;
color: white;
font-weight: bold;
}
.level-0 {
background-color: var(--md-sys-color-error, #dc3545); /* Red */
}
.level-1 {
background-color: var(--md-sys-color-secondary, #fd7e14); /* Orange */
}
.level-2 {
background-color: var(--md-nav-section-color-other, #28a745); /* Green */
}
.level-3 {
background-color: var(--md-sys-color-tertiary, #17a2b8); /* Teal */
}
/* Custom data table styling */
.audit-table {
font-size: 0.9rem;
color: var(--md-sys-color-on-surface);
border-color: var(--md-sys-color-outline-variant);
}
.audit-table tbody tr {
background-color: var(--md-sys-color-surface-container-low);
}
.audit-table tbody tr:nth-child(even) {
background-color: var(--md-sys-color-surface-container);
}
.audit-table tbody tr:hover {
background-color: var(--md-sys-color-surface-container-high);
}
.audit-table th {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
position: sticky;
top: 0;
z-index: 10;
font-weight: bold;
}
.table-responsive {
max-height: 600px;
}
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
padding: 10px 0;
border-top: 1px solid var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface);
}
.pagination .page-item.active .page-link {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: white;
}
.pagination .page-link {
color: var(--bs-primary);
}
.pagination .page-link.disabled {
pointer-events: none;
color: var(--bs-secondary);
background-color: var(--bs-light);
}
.json-viewer {
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
border-radius: 4px;
padding: 15px;
max-height: 350px;
overflow-y: auto;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
border: 1px solid var(--md-sys-color-outline-variant);
margin-top: 10px;
}
/* Simple, minimal radio styling - no extras */
.form-check {
padding: 8px 0;
}
#debug-console {
position: fixed;
bottom: 0;
right: 0;
width: 400px;
height: 200px;
background: var(--md-sys-color-surface-container-highest, rgba(0,0,0,0.8));
color: var(--md-sys-color-tertiary, #0f0);
font-family: monospace;
font-size: 12px;
z-index: 9999;
overflow-y: auto;
padding: 10px;
border: 1px solid var(--md-sys-color-outline);
display: none; /* Changed to none by default, enable with key command */
}
/* Enhanced styling for radio buttons as buttons */
label.btn-outline-primary {
cursor: pointer;
transition: all 0.2s;
border-color: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
}
label.btn-outline-primary.active {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-color: var(--md-sys-color-primary);
}
label.btn-outline-primary input[type="radio"] {
cursor: pointer;
}
/* Modal overrides for dark mode */
.modal-content {
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
border-color: var(--md-sys-color-outline);
}
.modal-header {
border-bottom-color: var(--md-sys-color-outline-variant);
}
.modal-footer {
border-top-color: var(--md-sys-color-outline-variant);
}
/* Improved modal positioning */
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - 3.5rem);
}
.modal {
z-index: 1050;
}
/* Button overrides for theme consistency */
.btn-outline-primary {
color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.btn-outline-primary:hover {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.btn-outline-secondary {
color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
}
.btn-outline-secondary:hover {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
.btn-primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-color: var(--md-sys-color-primary);
}
.btn-secondary {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
border-color: var(--md-sys-color-secondary);
}

View File

@@ -1,394 +0,0 @@
/* modern-tables.css - Professional styling for data tables and related elements */
/* Main container - Reduced max-width from 1100px to 900px */
.data-container {
max-width: 900px;
margin: 2rem auto;
background-color: var(--md-sys-color-surface-container-lowest);
border-radius: 1rem;
padding: 0.5rem;
box-shadow: 0 2px 12px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.05);
}
/* Panel / Card */
.data-panel {
background-color: var(--md-sys-color-surface);
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.08);
overflow: hidden;
}
/* Header */
.data-header {
display: flex;
align-items: center;
padding: 1.25rem 1.5rem;
background-color: var(--md-sys-color-surface-variant);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.data-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.75rem;
}
.data-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
/* Content area */
.data-body {
padding: 1.5rem;
background-color: var(--md-sys-color-surface-container-low);
border-radius: 0.5rem;
}
/* Action buttons container */
.data-actions {
display: flex;
justify-content: center;
margin: 1rem 0 1.5rem;
gap: 0.75rem;
}
/* Can add these classes for different alignments */
.data-actions-start {
justify-content: flex-start;
}
.data-actions-end {
justify-content: flex-end;
}
/* Button styling */
.data-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s ease;
border: none;
cursor: pointer;
text-decoration: none;
}
/* Fixed button colors - normal state has more contrast now */
.data-btn-primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.data-btn-primary:hover {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-primary);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
.data-btn-secondary {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
.data-btn-secondary:hover {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-secondary);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
.data-btn-danger {
background-color: var(--md-sys-color-error);
color: var(--md-sys-color-on-error);
}
.data-btn-danger:hover {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
.data-btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
/* Icon button */
.data-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
background-color: transparent;
}
/* Fixed icon button colors */
.data-icon-btn-primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.data-icon-btn-primary:hover {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-primary);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
.data-icon-btn-danger {
background-color: var(--md-sys-color-error);
color: var(--md-sys-color-on-error);
}
.data-icon-btn-danger:hover {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
/* Table styling */
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.data-table th {
text-align: left;
padding: 1rem;
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
font-weight: 600;
position: sticky;
top: 0;
}
.data-table th:first-child {
border-top-left-radius: 0.5rem;
}
.data-table th:last-child {
border-top-right-radius: 0.5rem;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover {
background-color: rgba(var(--md-sys-color-surface-variant-rgb), 0.5);
}
/* Table action cells */
.data-action-cell {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-start;
}
.data-action-cell-center {
justify-content: center;
}
.data-action-cell-end {
justify-content: flex-end;
}
/* Status indicators */
.data-status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.data-status-success {
background-color: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-tertiary);
}
.data-status-danger {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
}
.data-status-warning {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-secondary);
}
.data-status-info {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-primary);
}
/* Stats/Info container */
.data-stats {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.data-stat-card {
background-color: var(--md-sys-color-surface-variant);
border-radius: 0.5rem;
padding: 1.25rem;
flex: 1;
min-width: 180px;
box-shadow: 0 2px 8px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.05);
}
.data-stat-label {
font-size: 0.875rem;
color: var(--md-sys-color-on-surface-variant);
margin-bottom: 0.5rem;
}
.data-stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--md-sys-color-on-surface);
}
/* Section title */
.data-section-title {
font-size: 1.25rem;
font-weight: 600;
margin: 1.5rem 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
/* Empty state styling */
.data-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--md-sys-color-on-surface-variant);
}
.data-empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.7;
}
.data-empty-text {
font-size: 1.125rem;
margin-bottom: 1.5rem;
}
/* Modal styling */
.data-modal {
border-radius: 0.75rem;
overflow: hidden;
}
.data-modal-header {
background-color: var(--md-sys-color-surface-variant);
padding: 1.25rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
display: flex;
align-items: center;
justify-content: space-between;
}
.data-modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Modal close button styling */
.data-btn-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
border: none;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
margin: 0;
}
.data-btn-close:hover {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
}
.data-btn-close .material-symbols-rounded {
font-size: 1.25rem;
}
.data-modal-body {
padding: 1.5rem;
}
.data-modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--md-sys-color-outline-variant);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Form elements */
.data-form-group {
margin-bottom: 1.25rem;
}
.data-form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.data-form-control {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid
}
.text-overflow {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,999 +0,0 @@
// Initialize variables
let currentPage = 0;
let pageSize = 20;
let totalPages = 0;
let typeFilter = '';
let principalFilter = '';
let startDateFilter = '';
let endDateFilter = '';
// Charts
let typeChart;
let userChart;
let timeChart;
// DOM elements - will properly initialize these during page load
let auditTableBody;
let pageSizeSelect;
let typeFilterInput;
let exportTypeFilterInput;
let principalFilterInput;
let startDateFilterInput;
let endDateFilterInput;
let applyFiltersButton;
let resetFiltersButton;
// Initialize page
// Theme change listener to redraw charts when theme changes
function setupThemeChangeListener() {
// Watch for theme changes (usually by a class on body or html element)
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'data-bs-theme' || mutation.attributeName === 'class') {
// Redraw charts with new theme colors if they exist
if (typeChart && userChart && timeChart) {
// If we have stats data cached, use it
if (window.cachedStatsData) {
renderCharts(window.cachedStatsData);
}
}
}
});
});
// Observe the document element for theme changes
observer.observe(document.documentElement, { attributes: true });
// Also observe body for class changes
observer.observe(document.body, { attributes: true });
}
document.addEventListener('DOMContentLoaded', function() {
// Initialize DOM references
auditTableBody = document.getElementById('auditTableBody');
pageSizeSelect = document.getElementById('pageSizeSelect');
typeFilterInput = document.getElementById('typeFilter');
exportTypeFilterInput = document.getElementById('exportTypeFilter');
principalFilterInput = document.getElementById('principalFilter');
startDateFilterInput = document.getElementById('startDateFilter');
endDateFilterInput = document.getElementById('endDateFilter');
applyFiltersButton = document.getElementById('applyFilters');
resetFiltersButton = document.getElementById('resetFilters');
// Load event types for dropdowns
loadEventTypes();
// Show a loading message immediately
if (auditTableBody) {
auditTableBody.innerHTML =
'<tr><td colspan="5" class="text-center"><div class="spinner-border spinner-border-sm" role="status"></div> ' + window.i18n.loading + '</td></tr>';
}
// Make a direct API call first to avoid validation issues
loadAuditData(0, pageSize);
// Load statistics for dashboard
loadStats(7);
// Setup theme change listener
setupThemeChangeListener();
// Set up event listeners
pageSizeSelect.addEventListener('change', function() {
pageSize = parseInt(this.value);
window.originalPageSize = pageSize;
currentPage = 0;
window.requestedPage = 0;
loadAuditData(0, pageSize);
});
applyFiltersButton.addEventListener('click', function() {
typeFilter = typeFilterInput.value.trim();
principalFilter = principalFilterInput.value.trim();
startDateFilter = startDateFilterInput.value;
endDateFilter = endDateFilterInput.value;
currentPage = 0;
window.requestedPage = 0;
loadAuditData(0, pageSize);
});
resetFiltersButton.addEventListener('click', function() {
// Reset input fields
typeFilterInput.value = '';
principalFilterInput.value = '';
startDateFilterInput.value = '';
endDateFilterInput.value = '';
// Reset filter variables
typeFilter = '';
principalFilter = '';
startDateFilter = '';
endDateFilter = '';
// Reset page
currentPage = 0;
window.requestedPage = 0;
// Update UI
document.getElementById('currentPage').textContent = '1';
// Load data with reset filters
loadAuditData(0, pageSize);
});
// Reset export filters button
document.getElementById('resetExportFilters').addEventListener('click', function() {
exportTypeFilter.value = '';
exportPrincipalFilter.value = '';
exportStartDateFilter.value = '';
exportEndDateFilter.value = '';
});
// Make radio buttons behave like toggle buttons
const radioLabels = document.querySelectorAll('label.btn-outline-primary');
radioLabels.forEach(label => {
const radio = label.querySelector('input[type="radio"]');
if (radio) {
// Highlight the checked radio button's label
if (radio.checked) {
label.classList.add('active');
}
// Handle clicking on the label
label.addEventListener('click', function() {
// Remove active class from all labels
radioLabels.forEach(l => l.classList.remove('active'));
// Add active class to this label
this.classList.add('active');
// Check this radio button
radio.checked = true;
});
}
});
// Handle export button
exportButton.onclick = function(e) {
e.preventDefault();
// Get selected format with fallback
const selectedRadio = document.querySelector('input[name="exportFormat"]:checked');
const exportFormat = selectedRadio ? selectedRadio.value : 'csv';
exportAuditData(exportFormat);
return false;
};
// Set up pagination buttons
document.getElementById('page-first').onclick = function() {
if (currentPage > 0) {
goToPage(0);
}
return false;
};
document.getElementById('page-prev').onclick = function() {
if (currentPage > 0) {
goToPage(currentPage - 1);
}
return false;
};
document.getElementById('page-next').onclick = function() {
if (currentPage < totalPages - 1) {
goToPage(currentPage + 1);
}
return false;
};
document.getElementById('page-last').onclick = function() {
if (totalPages > 0 && currentPage < totalPages - 1) {
goToPage(totalPages - 1);
}
return false;
};
// Set up tab change events
const tabEls = document.querySelectorAll('button[data-bs-toggle="tab"]');
tabEls.forEach(tabEl => {
tabEl.addEventListener('shown.bs.tab', function (event) {
const targetId = event.target.getAttribute('data-bs-target');
if (targetId === '#dashboard') {
// Redraw charts when dashboard tab is shown
if (typeChart) typeChart.update();
if (userChart) userChart.update();
if (timeChart) timeChart.update();
}
});
});
});
// Load audit data from server
function loadAuditData(targetPage, realPageSize) {
const requestedPage = targetPage !== undefined ? targetPage : window.requestedPage || 0;
realPageSize = realPageSize || pageSize;
showLoading('table-loading');
// Always request page 0 from server, but with increased page size if needed
let url = `/api/v1/audit/data?page=${requestedPage}&size=${realPageSize}`;
if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`;
if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`;
if (startDateFilter) url += `&startDate=${startDateFilter}`;
if (endDateFilter) url += `&endDate=${endDateFilter}`;
// Update page indicator
if (document.getElementById('page-indicator')) {
document.getElementById('page-indicator').textContent = `Page ${requestedPage + 1} of ?`;
}
fetchWithCsrf(url)
.then(response => {
return response.json();
})
.then(data => {
// Calculate the correct slice of data to show for the requested page
let displayContent = data.content;
// Render the correct slice of data
renderTable(displayContent);
// Calculate total pages based on the actual total elements
const calculatedTotalPages = Math.ceil(data.totalElements / realPageSize);
totalPages = calculatedTotalPages;
currentPage = requestedPage; // Use our tracked page, not server's
// Update UI
document.getElementById('currentPage').textContent = currentPage + 1;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('totalRecords').textContent = data.totalElements;
if (document.getElementById('page-indicator')) {
document.getElementById('page-indicator').textContent = `Page ${currentPage + 1} of ${totalPages}`;
}
// Re-enable buttons with correct state
document.getElementById('page-first').disabled = currentPage === 0;
document.getElementById('page-prev').disabled = currentPage === 0;
document.getElementById('page-next').disabled = currentPage >= totalPages - 1;
document.getElementById('page-last').disabled = currentPage >= totalPages - 1;
hideLoading('table-loading');
// Restore original page size for next operations
if (window.originalPageSize && realPageSize !== window.originalPageSize) {
pageSize = window.originalPageSize;
}
// Store original page size for recovery
window.originalPageSize = realPageSize;
// Clear busy flag
window.paginationBusy = false;
})
.catch(error => {
if (auditTableBody) {
auditTableBody.innerHTML = `<tr><td colspan="5" class="text-center">${window.i18n.errorLoading} ${error.message}</td></tr>`;
}
hideLoading('table-loading');
// Re-enable buttons
document.getElementById('page-first').disabled = false;
document.getElementById('page-prev').disabled = false;
document.getElementById('page-next').disabled = false;
document.getElementById('page-last').disabled = false;
// Clear busy flag
window.paginationBusy = false;
});
}
// Load statistics for charts
function loadStats(days) {
showLoading('type-chart-loading');
showLoading('user-chart-loading');
showLoading('time-chart-loading');
fetchWithCsrf(`/api/v1/audit/stats?days=${days}`)
.then(response => response.json())
.then(data => {
document.getElementById('total-events').textContent = data.totalEvents;
// Cache stats data for theme changes
window.cachedStatsData = data;
renderCharts(data);
hideLoading('type-chart-loading');
hideLoading('user-chart-loading');
hideLoading('time-chart-loading');
})
.catch(error => {
console.error('Error loading stats:', error);
hideLoading('type-chart-loading');
hideLoading('user-chart-loading');
hideLoading('time-chart-loading');
});
}
// Export audit data
function exportAuditData(format) {
const type = exportTypeFilter.value.trim();
const principal = exportPrincipalFilter.value.trim();
const startDate = exportStartDateFilter.value;
const endDate = exportEndDateFilter.value;
let url = format === 'json' ? '/api/v1/audit/export/json?' : '/api/v1/audit/export/csv?';
if (type) url += `&type=${encodeURIComponent(type)}`;
if (principal) url += `&principal=${encodeURIComponent(principal)}`;
if (startDate) url += `&startDate=${startDate}`;
if (endDate) url += `&endDate=${endDate}`;
// Trigger download
window.location.href = url;
}
// Render table with audit data
function renderTable(events) {
if (!events || events.length === 0) {
auditTableBody.innerHTML = '<tr><td colspan="5" class="text-center">' + window.i18n.noEventsFound + '</td></tr>';
return;
}
try {
auditTableBody.innerHTML = '';
events.forEach((event, index) => {
try {
const row = document.createElement('tr');
row.innerHTML = `
<td>${event.id || 'N/A'}</td>
<td>${formatDate(event.timestamp)}</td>
<td>${escapeHtml(event.principal || 'N/A')}</td>
<td>${escapeHtml(event.type || 'N/A')}</td>
<td><button class="btn btn-sm btn-outline-primary view-details">${window.i18n.viewDetails || 'View Details'}</button></td>
`;
// Store event data for modal
row.dataset.event = JSON.stringify(event);
// Add click handler for details button
const detailsButton = row.querySelector('.view-details');
if (detailsButton) {
detailsButton.addEventListener('click', function() {
showEventDetails(event);
});
}
auditTableBody.appendChild(row);
} catch (rowError) {
}
});
} catch (e) {
auditTableBody.innerHTML = '<tr><td colspan="5" class="text-center">' + window.i18n.errorRendering + ' ' + e.message + '</td></tr>';
}
}
// Show event details in modal
function showEventDetails(event) {
// Get modal elements by ID with correct hyphenated IDs from HTML
const modalId = document.getElementById('modal-id');
const modalPrincipal = document.getElementById('modal-principal');
const modalType = document.getElementById('modal-type');
const modalTimestamp = document.getElementById('modal-timestamp');
const modalData = document.getElementById('modal-data');
const eventDetailsModal = document.getElementById('eventDetailsModal');
// Set modal content
if (modalId) modalId.textContent = event.id;
if (modalPrincipal) modalPrincipal.textContent = event.principal;
if (modalType) modalType.textContent = event.type;
if (modalTimestamp) modalTimestamp.textContent = formatDate(event.timestamp);
// Format JSON data
if (modalData) {
try {
const dataObj = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
modalData.textContent = JSON.stringify(dataObj, null, 2);
} catch (e) {
modalData.textContent = event.data || 'No data available';
}
}
// Show the modal
if (eventDetailsModal) {
const modal = new bootstrap.Modal(eventDetailsModal);
modal.show();
}
}
// No need for a dynamic pagination renderer anymore as we're using static buttons
// Direct pagination approach - server seems to be hard-limited to returning 20 items
function goToPage(page) {
// Basic validation - totalPages may not be initialized on first load
if (page < 0) {
return;
}
// Skip validation against totalPages on first load
if (totalPages > 0 && page >= totalPages) {
return;
}
// Simple guard flag
if (window.paginationBusy) {
return;
}
window.paginationBusy = true;
try {
// Store the requested page for later
window.requestedPage = page;
currentPage = page;
// Update UI immediately for user feedback
document.getElementById('currentPage').textContent = page + 1;
// Load data with this page
loadAuditData(page, pageSize);
} catch (e) {
window.paginationBusy = false;
}
}
// Render charts
function renderCharts(data) {
// Get theme colors
const colors = getThemeColors();
// Prepare data for charts
const typeLabels = Object.keys(data.eventsByType);
const typeValues = Object.values(data.eventsByType);
const userLabels = Object.keys(data.eventsByPrincipal);
const userValues = Object.values(data.eventsByPrincipal);
// Sort days for time chart
const timeLabels = Object.keys(data.eventsByDay).sort();
const timeValues = timeLabels.map(day => data.eventsByDay[day] || 0);
// Chart.js global defaults for dark mode compatibility
Chart.defaults.color = colors.text;
Chart.defaults.borderColor = colors.grid;
// Type chart
if (typeChart) {
typeChart.destroy();
}
const typeCtx = document.getElementById('typeChart').getContext('2d');
typeChart = new Chart(typeCtx, {
type: 'bar',
data: {
labels: typeLabels,
datasets: [{
label: window.i18n.eventsByType,
data: typeValues,
backgroundColor: colors.chartColors.slice(0, typeLabels.length).map(color => {
// Add transparency to the colors
if (color.startsWith('rgb(')) {
return color.replace('rgb(', 'rgba(').replace(')', ', 0.8)');
}
return color;
}),
borderColor: colors.chartColors.slice(0, typeLabels.length),
borderWidth: 2,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
usePointStyle: true,
pointStyle: 'rectRounded',
boxWidth: 12,
boxHeight: 12,
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6,
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.raw}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
precision: 0 // Only show whole numbers
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Count',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
}
}
},
x: {
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 11
},
callback: function(value, index) {
// Get the original label
const label = this.getLabelForValue(value);
// If the label is too long, truncate it
const maxLength = 10;
if (label.length > maxLength) {
return label.substring(0, maxLength) + '...';
}
return label;
},
autoSkip: true,
maxRotation: 0,
minRotation: 0
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid,
display: false // Hide vertical gridlines for cleaner look
},
title: {
display: true,
text: 'Event Type',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
padding: {top: 10, bottom: 0}
}
}
}
}
});
// User chart
if (userChart) {
userChart.destroy();
}
const userCtx = document.getElementById('userChart').getContext('2d');
userChart = new Chart(userCtx, {
type: 'pie',
data: {
labels: userLabels,
datasets: [{
label: window.i18n.eventsByUser,
data: userValues,
backgroundColor: colors.chartColors.slice(0, userLabels.length),
borderWidth: 2,
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.5)'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
color: colors.text,
font: {
size: colors.isDarkMode ? 14 : 12,
weight: colors.isDarkMode ? 'bold' : 'normal'
},
padding: 15,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 10,
boxHeight: 10,
// Add a box around each label for better contrast in dark mode
generateLabels: function(chart) {
const original = Chart.overrides.pie.plugins.legend.labels.generateLabels;
const labels = original.call(this, chart);
if (colors.isDarkMode) {
labels.forEach(label => {
// Enhance contrast for dark mode
label.fillStyle = label.fillStyle; // Keep original fill
label.strokeStyle = 'rgba(255, 255, 255, 0.8)'; // White border
label.lineWidth = 2; // Thicker border
});
}
return labels;
}
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6
}
}
}
});
// Time chart
if (timeChart) {
timeChart.destroy();
}
const timeCtx = document.getElementById('timeChart').getContext('2d');
// Get first color for line chart with appropriate transparency
let bgColor, borderColor;
if (colors.isDarkMode) {
bgColor = 'rgba(162, 201, 255, 0.3)'; // Light blue with transparency
borderColor = 'rgb(162, 201, 255)'; // Light blue solid
} else {
bgColor = 'rgba(0, 96, 170, 0.2)'; // Dark blue with transparency
borderColor = 'rgb(0, 96, 170)'; // Dark blue solid
}
timeChart = new Chart(timeCtx, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: window.i18n.eventsOverTime,
data: timeValues,
backgroundColor: bgColor,
borderColor: borderColor,
borderWidth: 3,
tension: 0.2,
fill: true,
pointBackgroundColor: borderColor,
pointBorderColor: colors.isDarkMode ? '#fff' : '#000',
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
usePointStyle: true,
pointStyle: 'line',
boxWidth: 50,
boxHeight: 3
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6,
callbacks: {
label: function(context) {
return `Events: ${context.raw}`;
}
}
}
},
interaction: {
intersect: false,
mode: 'index'
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
precision: 0 // Only show whole numbers
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Number of Events',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
}
}
},
x: {
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
maxRotation: 45,
minRotation: 45
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Date',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
padding: {top: 20}
}
}
}
}
});
}
// Helper functions
function formatDate(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}
function escapeHtml(text) {
if (!text) return '';
return text
.toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function showLoading(id) {
const loading = document.getElementById(id);
if (loading) loading.style.display = 'flex';
}
function hideLoading(id) {
const loading = document.getElementById(id);
if (loading) loading.style.display = 'none';
}
// Load event types from the server for filter dropdowns
function loadEventTypes() {
fetchWithCsrf('/api/v1/audit/types')
.then(response => response.json())
.then(types => {
if (!types || types.length === 0) {
return;
}
// Populate the type filter dropdowns
const typeFilter = document.getElementById('typeFilter');
const exportTypeFilter = document.getElementById('exportTypeFilter');
// Clear existing options except the first one (All event types)
while (typeFilter.options.length > 1) {
typeFilter.remove(1);
}
while (exportTypeFilter.options.length > 1) {
exportTypeFilter.remove(1);
}
// Add new options
types.forEach(type => {
// Main filter dropdown
const option = document.createElement('option');
option.value = type;
option.textContent = type;
typeFilter.appendChild(option);
// Export filter dropdown
const exportOption = document.createElement('option');
exportOption.value = type;
exportOption.textContent = type;
exportTypeFilter.appendChild(exportOption);
});
})
.catch(error => {
console.error('Error loading event types:', error);
});
}
// Get theme colors for charts
function getThemeColors() {
const isDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark';
// In dark mode, use higher contrast colors for text
const textColor = isDarkMode ?
'rgb(255, 255, 255)' : // White for dark mode for maximum contrast
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-on-surface').trim();
// Use a more visible grid color in dark mode
const gridColor = isDarkMode ?
'rgba(255, 255, 255, 0.2)' : // Semi-transparent white for dark mode
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-outline-variant').trim();
// Define bright, high-contrast colors for both dark and light modes
const chartColorsDark = [
'rgb(162, 201, 255)', // Light blue - primary
'rgb(193, 194, 248)', // Light purple - tertiary
'rgb(255, 180, 171)', // Light red - error
'rgb(72, 189, 84)', // Green - other
'rgb(25, 177, 212)', // Cyan - convert
'rgb(25, 101, 212)', // Blue - sign
'rgb(255, 120, 146)', // Pink - security
'rgb(104, 220, 149)', // Light green - convertto
'rgb(212, 172, 25)', // Yellow - image
'rgb(245, 84, 84)', // Red - advance
];
const chartColorsLight = [
'rgb(0, 96, 170)', // Blue - primary
'rgb(88, 90, 138)', // Purple - tertiary
'rgb(186, 26, 26)', // Red - error
'rgb(72, 189, 84)', // Green - other
'rgb(25, 177, 212)', // Cyan - convert
'rgb(25, 101, 212)', // Blue - sign
'rgb(255, 120, 146)', // Pink - security
'rgb(104, 220, 149)', // Light green - convertto
'rgb(212, 172, 25)', // Yellow - image
'rgb(245, 84, 84)', // Red - advance
];
return {
text: textColor,
grid: gridColor,
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface-container').trim(),
chartColors: isDarkMode ? chartColorsDark : chartColorsLight,
isDarkMode: isDarkMode
};
}
// Function to generate a palette of colors for charts
function getChartColors(count, opacity = 0.6) {
try {
// Use theme colors first
const themeColors = getThemeColors();
if (themeColors && themeColors.chartColors && themeColors.chartColors.length > 0) {
const result = [];
for (let i = 0; i < count; i++) {
// Get the raw color and add opacity
let color = themeColors.chartColors[i % themeColors.chartColors.length];
// If it's rgb() format, convert to rgba()
if (color.startsWith('rgb(')) {
color = color.replace('rgb(', '').replace(')', '');
result.push(`rgba(${color}, ${opacity})`);
} else {
// Just use the color directly
result.push(color);
}
}
return result;
}
} catch (e) {
console.warn('Error using theme colors, falling back to default colors', e);
}
// Base colors - a larger palette than the default
const colors = [
[54, 162, 235], // blue
[255, 99, 132], // red
[75, 192, 192], // teal
[255, 206, 86], // yellow
[153, 102, 255], // purple
[255, 159, 64], // orange
[46, 204, 113], // green
[231, 76, 60], // dark red
[52, 152, 219], // light blue
[155, 89, 182], // violet
[241, 196, 15], // dark yellow
[26, 188, 156], // turquoise
[230, 126, 34], // dark orange
[149, 165, 166], // light gray
[243, 156, 18], // amber
[39, 174, 96], // emerald
[211, 84, 0], // dark orange red
[22, 160, 133], // green sea
[41, 128, 185], // belize hole
[142, 68, 173] // wisteria
];
const result = [];
// Always use the same format regardless of color source
if (count > colors.length) {
// Generate colors algorithmically for large sets
for (let i = 0; i < count; i++) {
// Generate a color based on position in the hue circle (0-360)
const hue = (i * 360 / count) % 360;
const sat = 70 + Math.random() * 10; // 70-80%
const light = 50 + Math.random() * 10; // 50-60%
result.push(`hsla(${hue}, ${sat}%, ${light}%, ${opacity})`);
}
} else {
// Use colors from our palette but also return in hsla format for consistency
for (let i = 0; i < count; i++) {
const color = colors[i % colors.length];
result.push(`rgba(${color[0]}, ${color[1]}, ${color[2]}, ${opacity})`);
}
}
return result;
}

View File

@@ -1,205 +0,0 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{team.details.title}, header=#{team.details.header})}"></th:block>
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div class="data-container">
<div class="data-panel">
<div class="data-header">
<h1 class="data-title">
<span class="data-icon">
<span class="material-symbols-rounded">group</span>
</span>
<span th:text="'Team: ' + ${team.name}">Team Name</span>
</h1>
</div>
<div class="data-body">
<div class="data-stats">
<div class="data-stat-card">
<div class="data-stat-label" th:text="#{team.totalMembers}">Total Members:</div>
<div class="data-stat-value" th:text="${teamUsers.size()}">1</div>
</div>
</div>
<!-- Alert Messages -->
<div th:if="${errorMessage}" class="alert alert-danger data-mb-3">
<span th:text="#{${errorMessage}}">Default message if not found</span>
</div>
<div class="data-actions data-actions-start">
<a th:href="@{'/teams'}" class="data-btn data-btn-secondary">
<span class="material-symbols-rounded">arrow_back</span>
<span th:text="#{team.back}">Back to Teams</span>
</a>
</div>
<div class="data-section-title" th:text="#{team.members}">Members</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th th:text="#{team.username}">Username</th>
<th th:text="#{team.role}">Role</th>
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : #{proFeatures}"
class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th th:text="#{team.status}">Status</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${teamUsers}">
<td th:text="${user.id}">1</td>
<td th:text="${user.username}">username</td>
<td th:text="#{${user.roleName}}">Role</td>
<td
th:text="${@runningProOrHigher} ? (${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : #{team.hidden}">
2023-01-01 12:00:00</td>
<td>
<span th:if="${user.enabled}" class="data-status data-status-success">
<span class="material-symbols-rounded">person</span>
<span th:text="#{team.enabled}">Enabled</span>
</span>
<span th:unless="${user.enabled}" class="data-status data-status-danger">
<span class="material-symbols-rounded">person_off</span>
<span th:text="#{team.disabled}">Disabled</span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state for when there are no team members -->
<div th:if="${teamUsers.empty}" class="data-empty">
<span class="material-symbols-rounded data-empty-icon">person_off</span>
<p class="data-empty-text" th:text="#{team.noMembers}">This team has no members yet.</p>
<button data-bs-toggle="modal" data-bs-target="#addUserToTeamModal" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">person_add</span>
<span th:text="#{team.addUser}">Add User to Team</span>
</button>
</div>
<!-- Add button for non-empty teams too -->
<div th:if="${!teamUsers.empty}" class="data-actions data-mt-3">
<button data-bs-toggle="modal" data-bs-target="#addUserToTeamModal" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">person_add</span>
<span th:text="#{team.addUser}">Add User to Team</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript for team warning -->
<script th:inline="javascript">
function checkUserTeam(userId) {
// Clear any existing warning
const warningDiv = document.getElementById('teamChangeWarning');
const warningMessage = document.getElementById('warningMessage');
const submitButton = document.getElementById('addUserSubmitBtn');
// Reset
warningDiv.style.display = 'none';
submitButton.onclick = null;
// Get the selected option
const selectedOption = document.querySelector('#userId option[value="' + userId + '"]');
if (!selectedOption) return;
// Get team data
const currentTeam = selectedOption.getAttribute('data-team');
const currentTeamId = selectedOption.getAttribute('data-team-id');
const newTeamName = /*[[${team.name}]]*/ 'Current Team';
// If user is already in a team, show warning
if (currentTeam && currentTeam.length > 0) {
// Use internationalized message
const warningTemplate = /*[[#{team.warning.moveUser}]]*/ 'Warning: This will move the user from "{0}" team to "{1}" team. Are you sure?';
const formattedWarning = warningTemplate.replace('{0}', currentTeam).replace('{1}', newTeamName);
warningMessage.textContent = formattedWarning;
warningDiv.style.display = 'block';
// Add confirmation to submit button
submitButton.onclick = function (e) {
// Use internationalized message
const confirmTemplate = /*[[#{team.confirm.moveUser}]]*/ 'Are you sure you want to move this user from "{0}" team to "{1}" team?';
const formattedConfirm = confirmTemplate.replace('{0}', currentTeam).replace('{1}', newTeamName);
if (!confirm(formattedConfirm)) {
e.preventDefault();
return false;
}
return true;
};
}
}
</script>
<!-- Add User to Team Modal -->
<div class="modal fade" id="addUserToTeamModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form th:action="@{'/api/v1/team/addUser'}" method="post" class="modal-content data-modal">
<div class="data-modal-header">
<h5 class="data-modal-title">
<span class="data-icon">
<span class="material-symbols-rounded">person_add</span>
</span>
<span th:text="#{team.addUser}">Add User to Team</span>
</h5>
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="data-modal-body">
<input type="hidden" name="teamId" th:value="${team.id}" />
<div class="data-form-group">
<label for="userId" class="data-form-label" th:text="#{team.selectUser}">Select User</label>
<select name="userId" id="userId" class="data-form-control" required onchange="checkUserTeam(this.value)">
<option value="" disabled selected th:text="#{selectFilter}">-- Select User --</option>
<option th:each="user : ${availableUsers}" th:value="${user.id}" th:text="${user.username}"
th:data-team="${user.team != null ? user.team.name : ''}"
th:data-team-id="${user.team != null ? user.team.id : ''}">
Username
</option>
</select>
</div>
<!-- Warning message for users being moved between teams -->
<div id="teamChangeWarning" class="alert alert-warning mt-3" style="display: none;">
<span class="material-symbols-rounded">warning</span>
<span id="warningMessage">Warning: This will move the user from their current team to this team.</span>
</div>
<div class="data-form-actions">
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
<span class="material-symbols-rounded">close</span>
<span th:text="#{cancel}">Cancel</span>
</button>
<button type="submit" id="addUserSubmitBtn" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">check</span>
<span th:text="#{team.addUser}">Add User</span>
</button>
</div>
</div>
</form>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>

View File

@@ -1,158 +0,0 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block
th:insert="~{fragments/common :: head(title=#{adminUserSettings.manageTeams}, header=#{adminUserSettings.manageTeams})}">
</th:block>
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div class="data-container">
<div class="data-panel">
<div class="data-header">
<h1 class="data-title">
<span class="data-icon">
<span class="material-symbols-rounded">groups</span>
</span>
<span th:text="#{adminUserSettings.manageTeams}">Team Management</span>
</h1>
</div>
<div class="data-body">
<!-- Back Button -->
<div class="data-actions data-actions-start">
<a th:href="@{'/adminSettings'}" class="data-btn data-btn-secondary">
<span class="material-symbols-rounded">arrow_back</span>
<span th:text="#{back.toSettings}">Back to Settings</span>
</a>
</div>
<!-- Alert Messages -->
<div th:if="${addMessage}" class="alert alert-success data-mb-3">
<span th:text="#{${addMessage}}">Default message if not found</span>
</div>
<div th:if="${changeMessage}" class="alert alert-success data-mb-3">
<span th:text="#{${changeMessage}}">Default message if not found</span>
</div>
<div th:if="${deleteMessage}" class="alert alert-danger data-mb-3">
<span th:text="#{${deleteMessage}}">Default message if not found</span>
</div>
<div th:if="${errorMessage}" class="alert alert-danger data-mb-3">
<span th:text="#{${errorMessage}}">Default message if not found</span>
</div>
<!-- Create New Team Button -->
<div class="data-actions">
<a href="#" th:data-bs-toggle="${@runningProOrHigher} ? 'modal' : null"
th:data-bs-target="${@runningProOrHigher} ? '#addTeamModal' : null"
th:class="${@runningProOrHigher} ? 'data-btn data-btn-primary' : 'data-btn data-btn-danger'"
th:title="${@runningProOrHigher} ? #{adminUserSettings.createTeam} : #{enterpriseEdition.proTeamFeatureDisabled}">
<span class="material-symbols-rounded">group_add</span>
<span th:text="#{adminUserSettings.createTeam}">Create New Team</span>
</a>
</div>
<!-- Team Table -->
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th scope="col" th:text="#{adminUserSettings.teamName}">Team Name</th>
<th scope="col" th:text="#{adminUserSettings.totalMembers}">Total Members</th>
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : #{proFeatures}"
class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th scope="col" th:text="#{adminUserSettings.actions}">Actions</th>
</tr>
</thead>
<tbody>
<!-- Try approach 1 - DTO projection -->
<tr th:each="teamDto : ${teamsWithCounts}">
<td th:text="${teamDto.name}"></td>
<td th:text="${teamDto.userCount}"></td>
<td
th:text="${@runningProOrHigher} ? (${teamLastRequest[teamDto.id] != null ? #dates.format(teamLastRequest[teamDto.id], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : #{adminUserSettings.teamHidden}">
</td>
<td>
<div class="data-action-cell">
<a th:href="@{'/teams/' + ${teamDto.id}}" class="data-btn data-btn-secondary data-btn-sm"
th:title="#{adminUserSettings.viewTeam}">
<span class="material-symbols-rounded">search</span> <span th:text="#{view}">View</span>
</a>
<form th:action="@{'/api/v1/team/delete'}" method="post" style="display:inline-block"
onsubmit="return confirmDeleteTeam()">
<input type="hidden" name="teamId" th:value="${teamDto.id}" />
<button type="submit" class="data-btn data-btn-danger data-btn-sm"
th:disabled="${!@runningProOrHigher}" th:classappend="${!@runningProOrHigher} ? 'disabled' : ''"
th:title="${@runningProOrHigher} ? #{adminUserSettings.deleteTeam} : #{enterpriseEdition.proTeamFeatureDisabled}">
<span class="material-symbols-rounded">delete</span> <span th:text="#{delete}">Delete</span>
</button>
</form>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Delete Confirmation Script -->
<script th:inline="javascript">
const confirmDeleteText = /*[[#{adminUserSettings.confirmDeleteTeam}]]*/ 'Are you sure you want to delete this team?';
function confirmDeleteTeam() {
return confirm(confirmDeleteText);
}
</script>
</div>
</div>
</div>
<!-- Add Team Modal -->
<div class="modal fade" id="addTeamModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form th:action="@{'/api/v1/team/create'}" method="post" class="modal-content data-modal">
<div class="data-modal-header">
<h5 class="data-modal-title">
<span class="data-icon">
<span class="material-symbols-rounded">group_add</span>
</span>
<span th:text="#{adminUserSettings.createTeam}">Create Team</span>
</h5>
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="data-modal-body">
<div class="data-form-group">
<label for="teamName" class="data-form-label" th:text="#{adminUserSettings.teamName}">Team Name</label>
<input type="text" name="name" id="teamName" class="data-form-control" required />
</div>
<div class="data-form-actions">
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
<span class="material-symbols-rounded">close</span>
<span th:text="#{cancel}">Cancel</span>
</button>
<button type="submit" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">check</span>
<span th:text="#{adminUserSettings.createTeam}">Create</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>

View File

@@ -1,383 +0,0 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title='Audit Dashboard', header='Audit Dashboard')}"></th:block>
<!-- Include Chart.js for visualizations -->
<script th:src="@{/js/thirdParty/chart.umd.min.js}"></script>
<!-- Include custom CSS -->
<link rel="stylesheet" th:href="@{/css/audit-dashboard.css}" />
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div class="container-fluid mt-4">
<h1 class="mb-4" th:text="#{audit.dashboard.title}">Audit Dashboard</h1>
<!-- System Status Card -->
<div class="card dashboard-card mb-4">
<div class="card-header">
<h2 class="h5 mb-0" th:text="#{audit.dashboard.systemStatus}">Audit System Status</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.status}">Status</div>
<div class="stat-number">
<span th:if="${auditEnabled}" class="text-success" th:text="#{audit.dashboard.enabled}">Enabled</span>
<span th:unless="${auditEnabled}" class="text-danger" th:text="#{audit.dashboard.disabled}">Disabled</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.currentLevel}">Current Level</div>
<div class="stat-number">
<span th:class="'level-indicator level-' + ${auditLevelInt}" th:text="${auditLevel}">STANDARD</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.retentionPeriod}">Retention Period</div>
<div class="stat-number" th:text="${retentionDays} + ' ' + #{audit.dashboard.days}">90 days</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.totalEvents}">Total Events</div>
<div class="stat-number" id="total-events">-</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tabs for different sections -->
<ul class="nav nav-tabs" id="auditTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard" type="button" role="tab" aria-controls="dashboard" aria-selected="true" th:text="#{audit.dashboard.tab.dashboard}">Dashboard</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab" aria-controls="events" aria-selected="false" th:text="#{audit.dashboard.tab.events}">Audit Events</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="export-tab" data-bs-toggle="tab" data-bs-target="#export" type="button" role="tab" aria-controls="export" aria-selected="false" th:text="#{audit.dashboard.tab.export}">Export</button>
</li>
<li class="nav-item" role="presentation">
</li>
</ul>
<div class="tab-content" id="auditTabsContent">
<!-- Dashboard Tab -->
<div class="tab-pane fade show active" id="dashboard" role="tabpanel" aria-labelledby="dashboard-tab">
<div class="row mt-4">
<div class="col-md-6">
<div class="card dashboard-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByType}">Events by Type</h3>
<div class="btn-group">
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(7)" th:text="#{audit.dashboard.period.7days}">7 Days</button>
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(30)" th:text="#{audit.dashboard.period.30days}">30 Days</button>
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(90)" th:text="#{audit.dashboard.period.90days}">90 Days</button>
</div>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="type-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="typeChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card dashboard-card">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByUser}">Events by User</h3>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="user-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="userChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card dashboard-card">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsOverTime}">Events Over Time</h3>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="time-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="timeChart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Events Tab -->
<div class="tab-pane fade" id="events" role="tabpanel" aria-labelledby="events-tab">
<div class="card dashboard-card mt-4">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.auditEvents}">Audit Events</h3>
</div>
<div class="card-body">
<!-- Filters -->
<div class="card filter-card">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="typeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label>
<select class="form-select" id="typeFilter">
<option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option>
<!-- Will be populated from API -->
</select>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="principalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label>
<input type="text" class="form-control" id="principalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="startDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label>
<input type="date" class="form-control" id="startDateFilter">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="endDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label>
<input type="date" class="form-control" id="endDateFilter">
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<button id="applyFilters" class="btn btn-primary" th:text="#{audit.dashboard.filter.apply}">Apply Filters</button>
<button id="resetFilters" class="btn btn-secondary" th:text="#{reset}">Reset</button>
</div>
</div>
</div>
<!-- Event Table -->
<div class="table-responsive position-relative">
<div class="loading-overlay" id="table-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<table class="table table-striped table-hover audit-table">
<thead>
<tr>
<th th:text="#{audit.dashboard.table.id}">ID</th>
<th th:text="#{audit.dashboard.table.time}">Time</th>
<th th:text="#{audit.dashboard.table.user}">User</th>
<th th:text="#{audit.dashboard.table.type}">Type</th>
<th th:text="#{audit.dashboard.table.details}">Details</th>
</tr>
</thead>
<tbody id="auditTableBody">
<!-- Table rows will be populated by JavaScript -->
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination-container">
<div>
<span th:text="#{audit.dashboard.pagination.show}">Show</span>
<select id="pageSizeSelect" class="form-select form-select-sm d-inline-block w-auto mx-2">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span th:text="#{audit.dashboard.pagination.entries}">entries</span>
<span class="mx-3" th:text="#{audit.dashboard.pagination.pageInfo1}">Page </span><span id="currentPage">1</span> <span th:text="#{audit.dashboard.pagination.pageInfo2}">of</span> <span id="totalPages">1</span> (<span th:text="#{audit.dashboard.pagination.totalRecords}">Total records:</span> <span id="totalRecords">0</span>)
</div>
<nav aria-label="Audit events pagination">
<div class="btn-group" role="group" aria-label="Pagination">
<button type="button" class="btn btn-outline-primary" id="page-first">&laquo;</button>
<button type="button" class="btn btn-outline-primary" id="page-prev">&lsaquo;</button>
<span class="btn btn-outline-secondary disabled" id="page-indicator">Page 1 of 1</span>
<button type="button" class="btn btn-outline-primary" id="page-next">&rsaquo;</button>
<button type="button" class="btn btn-outline-primary" id="page-last">&raquo;</button>
</div>
</nav>
</div>
</div>
</div>
<!-- Event Details Modal -->
<div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="eventDetailsModalLabel" th:text="#{audit.dashboard.modal.eventDetails}">Event Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" th:aria-label="#{close}" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.id} + ':'">ID:</strong> <span id="modal-id"></span>
</div>
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.user} + ':'">User:</strong> <span id="modal-principal"></span>
</div>
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.type} + ':'">Type:</strong> <span id="modal-type"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<strong th:text="#{audit.dashboard.modal.time} + ':'">Time:</strong> <span id="modal-timestamp"></span>
</div>
</div>
<div class="row">
<div class="col-md-12">
<strong th:text="#{audit.dashboard.modal.data} + ':'">Data:</strong>
<div class="json-viewer" id="modal-data"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}">Close</button>
</div>
</div>
</div>
</div>
</div>
<!-- Export Tab -->
<div class="tab-pane fade" id="export" role="tabpanel" aria-labelledby="export-tab">
<div class="card dashboard-card mt-4">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.export.title}">Export Audit Data</h3>
</div>
<div class="card-body">
<!-- Export Filters -->
<div class="card filter-card">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="exportTypeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label>
<select class="form-select" id="exportTypeFilter">
<option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option>
<!-- Will be populated from API -->
</select>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportPrincipalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label>
<input type="text" class="form-control" id="exportPrincipalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportStartDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label>
<input type="date" class="form-control" id="exportStartDateFilter">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportEndDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label>
<input type="date" class="form-control" id="exportEndDateFilter">
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<h5 th:text="#{audit.dashboard.export.format}">Export Format</h5>
<div>
<label class="btn btn-outline-primary" style="margin-right: 10px;">
<input type="radio" name="exportFormat" id="formatCSV" value="csv" checked style="margin-right: 5px;">
<span th:text="#{audit.dashboard.export.csv}">CSV (Comma Separated Values)</span>
</label>
<label class="btn btn-outline-primary">
<input type="radio" name="exportFormat" id="formatJSON" value="json" style="margin-right: 5px;">
<span th:text="#{audit.dashboard.export.json}">JSON (JavaScript Object Notation)</span>
</label>
</div>
</div>
<div class="col-md-6">
<button id="exportButton" class="btn btn-primary mt-4">
<i class="bi bi-download"></i> <span th:text="#{audit.dashboard.export.button}">Export Data</span>
</button>
<button id="resetExportFilters" class="btn btn-secondary mt-4 ms-2">
<span th:text="#{audit.dashboard.filter.reset}">Reset Filters</span>
</button>
</div>
</div>
</div>
<div class="alert alert-info mt-3">
<h5 th:text="#{audit.dashboard.export.infoTitle}">Export Information</h5>
<p th:text="#{audit.dashboard.export.infoDesc1}">The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate.</p>
<p th:text="#{audit.dashboard.export.infoDesc2}">Exported data will include:</p>
<ul>
<li th:text="#{audit.dashboard.export.infoItem1}">Event ID</li>
<li th:text="#{audit.dashboard.export.infoItem2}">User</li>
<li th:text="#{audit.dashboard.export.infoItem3}">Event Type</li>
<li th:text="#{audit.dashboard.export.infoItem4}">Timestamp</li>
<li th:text="#{audit.dashboard.export.infoItem5}">Event Data</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS is loaded by the common fragments -->
<script th:src="@{/js/thirdParty/jquery.min.js}"></script>
<script th:src="@{/js/thirdParty/bootstrap.min.js}"></script>
<!-- Internationalization data for JavaScript -->
<script th:inline="javascript">
window.i18n = {
loading: /*[[#{loading}]]*/ 'Loading...',
noEventsFound: /*[[#{audit.dashboard.js.noEventsFound}]]*/ 'No audit events found matching the current filters',
errorLoading: /*[[#{audit.dashboard.js.errorLoading}]]*/ 'Error loading data:',
errorRendering: /*[[#{audit.dashboard.js.errorRendering}]]*/ 'Error rendering table:',
loadingPage: /*[[#{audit.dashboard.js.loadingPage}]]*/ 'Loading page',
eventsByType: /*[[#{audit.dashboard.eventsByType}]]*/ 'Events by Type',
eventsByUser: /*[[#{audit.dashboard.eventsByUser}]]*/ 'Events by User',
eventsOverTime: /*[[#{audit.dashboard.eventsOverTime}]]*/ 'Events Over Time',
viewDetails: /*[[#{audit.dashboard.table.viewDetails}]]*/ 'View Details'
};
</script>
<!-- Load custom JavaScript -->
<script th:src="@{/js/audit/dashboard.js}"></script>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>