Compare commits

...

4 Commits

Author SHA1 Message Date
Anthony Stirling
6dd17a5561 remove bad naming plus bump to 1.0 2025-06-11 02:07:03 +01:00
Anthony Stirling
bbdbfa67a0 cleanups 2025-06-11 02:04:00 +01:00
a
d43e3fb9fc Merge branch 'Team' of git@github.com:Stirling-Tools/Stirling-PDF.git into Team 2025-06-11 00:52:17 +01:00
Anthony Stirling
1fac660bf8 general fixes and changes 2025-06-11 00:52:03 +01:00
13 changed files with 477 additions and 174 deletions

View File

@ -74,7 +74,7 @@ sourceSets {
allprojects { allprojects {
group = 'stirling.software' group = 'stirling.software'
version = '0.47.0' version = '1.0.0'
configurations.configureEach { configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging' exclude group: 'commons-logging', module: 'commons-logging'

View File

@ -0,0 +1,20 @@
package stirling.software.proprietary.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class TeamWithUserCountDTO {
private Long id;
private String name;
private Long userCount;
// Constructor for JPQL projection
public TeamWithUserCountDTO(Long id, String name, Long userCount) {
this.id = id;
this.name = name;
this.userCount = userCount;
}
}

View File

@ -45,6 +45,7 @@ import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Controller @Controller
@ -226,14 +227,27 @@ public class AccountWebController {
while (iterator.hasNext()) { while (iterator.hasNext()) {
User user = iterator.next(); User user = iterator.next();
if (user != null) { if (user != null) {
boolean shouldRemove = false;
// Check if user is an INTERNAL_API_USER
for (Authority authority : user.getAuthorities()) { for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) { if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
iterator.remove(); shouldRemove = true;
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId()); roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
// Break out of the inner loop once the user is removed
break; break;
} }
} }
// Also check if user is part of the Internal team
if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
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 // Determine the user's session status and last request time
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
boolean hasActiveSession = false; boolean hasActiveSession = false;
@ -336,7 +350,11 @@ public class AccountWebController {
model.addAttribute("activeUsers", activeUsers); model.addAttribute("activeUsers", activeUsers);
model.addAttribute("disabledUsers", disabledUsers); model.addAttribute("disabledUsers", disabledUsers);
List<Team> allTeams = teamRepository.findAll(); // Get all teams but filter out the Internal team
List<Team> allTeams = teamRepository.findAll()
.stream()
.filter(team -> !team.getName().equals(stirling.software.proprietary.security.service.TeamService.INTERNAL_TEAM_NAME))
.toList();
model.addAttribute("teams", allTeams); model.addAttribute("teams", allTeams);
model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers()); model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers());

View File

@ -17,7 +17,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint; import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository; 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.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
@Controller @Controller
@RequestMapping("/api/v1/team") @RequestMapping("/api/v1/team")
@ -54,6 +56,12 @@ public class TeamController {
return new RedirectView("/adminSettings?messageType=teamNameExists"); return new RedirectView("/adminSettings?messageType=teamNameExists");
} }
Team team = existing.get(); Team team = existing.get();
// Prevent renaming the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible");
}
team.setName(newName); team.setName(newName);
teamRepository.save(team); teamRepository.save(team);
return new RedirectView("/adminSettings?messageType=teamRenamed"); return new RedirectView("/adminSettings?messageType=teamRenamed");
@ -69,6 +77,12 @@ public class TeamController {
} }
Team team = teamOpt.get(); Team team = teamOpt.get();
// Prevent deleting the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible");
}
long memberCount = userRepository.countByTeam(team); long memberCount = userRepository.countByTeam(team);
if (memberCount > 0) { if (memberCount > 0) {
return new RedirectView("/adminSettings?messageType=teamHasUsers"); return new RedirectView("/adminSettings?messageType=teamHasUsers");
@ -77,4 +91,37 @@ public class TeamController {
teamRepository.delete(team); teamRepository.delete(team);
return new RedirectView("/adminSettings?messageType=teamDeleted"); return new RedirectView("/adminSettings?messageType=teamDeleted");
} }
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/addUser")
@Transactional
public RedirectView addUserToTeam(
@RequestParam("teamId") Long teamId,
@RequestParam("userId") Long userId) {
// Find the team
Team team = teamRepository.findById(teamId)
.orElseThrow(() -> new RuntimeException("Team not found"));
// Prevent adding users to the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/teams?error=internalTeamNotAccessible");
}
// Find the user
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
// Check if user is in the Internal team - prevent moving them
if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
// Redirect back to team details page
return new RedirectView("/teams/" + teamId + "?messageType=userAdded");
}
} }

View File

@ -33,6 +33,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
@ -54,7 +55,7 @@ public class UserController {
private final SessionPersistentRegistry sessionRegistry; private final SessionPersistentRegistry sessionRegistry;
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private final TeamRepository teamRepository; private final TeamRepository teamRepository;
private final UserRepository userRepository;
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register") @PostMapping("/register")
public String register(@ModelAttribute UsernameAndPass requestModel, Model model) public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
@ -210,6 +211,7 @@ public class UserController {
@RequestParam(name = "username", required = true) String username, @RequestParam(name = "username", required = true) String username,
@RequestParam(name = "password", required = false) String password, @RequestParam(name = "password", required = false) String password,
@RequestParam(name = "role") String role, @RequestParam(name = "role") String role,
@RequestParam(name = "teamId", required = false) Long teamId,
@RequestParam(name = "authType") String authType, @RequestParam(name = "authType") String authType,
@RequestParam(name = "forceChange", required = false, defaultValue = "false") @RequestParam(name = "forceChange", required = false, defaultValue = "false")
boolean forceChange) boolean forceChange)
@ -243,14 +245,29 @@ public class UserController {
// If the role ID is not valid, redirect with an error message // If the role ID is not valid, redirect with an error message
return new RedirectView("/adminSettings?messageType=invalidRole", true); return new RedirectView("/adminSettings?messageType=invalidRole", true);
} }
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
// Use teamId if provided, otherwise use default team
Long effectiveTeamId = teamId;
if (effectiveTeamId == null) {
Team defaultTeam = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
if (defaultTeam != null) {
effectiveTeamId = defaultTeam.getId();
}
} else {
// Check if the selected team is Internal - prevent assigning to it
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
if (selectedTeam != null && selectedTeam.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible", true);
}
}
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) { if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
userService.saveUser(username, AuthenticationType.SSO, team, role); userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
} else { } else {
if (password.isBlank()) { if (password.isBlank()) {
return new RedirectView("/adminSettings?messageType=invalidPassword", true); return new RedirectView("/adminSettings?messageType=invalidPassword", true);
} }
userService.saveUser(username, password, team, role, forceChange); userService.saveUser(username, password, effectiveTeamId, role, forceChange);
} }
return new RedirectView( return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user "/adminSettings", // Redirect to account page after adding the user
@ -262,6 +279,7 @@ public class UserController {
public RedirectView changeRole( public RedirectView changeRole(
@RequestParam(name = "username") String username, @RequestParam(name = "username") String username,
@RequestParam(name = "role") String role, @RequestParam(name = "role") String role,
@RequestParam(name = "teamId", required = false) Long teamId,
Authentication authentication) Authentication authentication)
throws SQLException, UnsupportedProviderException { throws SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username); Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
@ -289,6 +307,26 @@ public class UserController {
return new RedirectView("/adminSettings?messageType=invalidRole", true); return new RedirectView("/adminSettings?messageType=invalidRole", true);
} }
User user = userOpt.get(); User user = userOpt.get();
// Update the team if a teamId is provided
if (teamId != null) {
Team team = teamRepository.findById(teamId).orElse(null);
if (team != null) {
// Prevent assigning to Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible", true);
}
// Prevent moving users from Internal team
if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/adminSettings?messageType=cannotMoveInternalUsers", true);
}
user.setTeam(team);
userRepository.save(user);
}
}
userService.changeRole(user, role); userService.changeRole(user, role);
return new RedirectView( return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user "/adminSettings", // Redirect to account page after adding the user

View File

@ -2,7 +2,6 @@ package stirling.software.proprietary.security.controller.web;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -14,27 +13,36 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.Team; 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.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
@Controller @Controller
@RequestMapping("/teams") @RequestMapping("/teams")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class TeamWebController { public class TeamWebController {
private final TeamRepository teamRepository; private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final SessionRepository sessionRepository; private final SessionRepository sessionRepository;
private final UserRepository userRepository;
@GetMapping @GetMapping
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
public String listTeams(Model model) { public String listTeams(Model model) {
// Get all teams with their users // Get teams with user counts using a DTO projection
List<Team> teams = teamRepository.findAllWithUsers(); List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
// Filter out the Internal team
List<TeamWithUserCountDTO> teamsWithCounts = allTeamsWithCounts.stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.toList();
// Get the latest activity for each team // Get the latest activity for each team
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam(); List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
@ -42,46 +50,56 @@ public class TeamWebController {
// Convert the query results to a map for easy access in the view // Convert the query results to a map for easy access in the view
Map<Long, Date> teamLastRequest = new HashMap<>(); Map<Long, Date> teamLastRequest = new HashMap<>();
for (Object[] result : teamActivities) { for (Object[] result : teamActivities) {
// For JPQL query with aliases
Long teamId = (Long) result[0]; // teamId alias Long teamId = (Long) result[0]; // teamId alias
Date lastActivity = (Date) result[1]; // lastActivity alias Date lastActivity = (Date) result[1]; // lastActivity alias
teamLastRequest.put(teamId, lastActivity); teamLastRequest.put(teamId, lastActivity);
} }
model.addAttribute("teams", teams); // Add data to the model
model.addAttribute("teamsWithCounts", teamsWithCounts);
model.addAttribute("teamLastRequest", teamLastRequest); model.addAttribute("teamLastRequest", teamLastRequest);
return "enterprise/teams";
return "accounts/teams";
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
public String viewTeamDetails(@PathVariable("id") Long id, Model model) { public String viewTeamDetails(@PathVariable("id") Long id, Model model) {
// Get the team with its users // Get the team
Team team = Team team = teamRepository.findById(id)
teamRepository
.findById(id)
.orElseThrow(() -> new RuntimeException("Team not found")); .orElseThrow(() -> new RuntimeException("Team not found"));
List<User> members = userRepository.findAllByTeam(team); // Prevent access to Internal team
team.setUsers(new HashSet<>(members)); if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
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 || !user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)))
.toList();
// Get the latest session for each user in the team // Get the latest session for each user in the team
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id); List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
// Create a map of username to last request date // Create a map of username to last request date
Map<String, Date> userLastRequest = new HashMap<>(); Map<String, Date> userLastRequest = new HashMap<>();
// Process results from JPQL query
for (Object[] result : userSessions) { for (Object[] result : userSessions) {
String username = (String) result[0]; // username alias String username = (String) result[0]; // username alias
Date lastRequest = (Date) result[1]; // lastRequest alias Date lastRequest = (Date) result[1]; // lastRequest alias
userLastRequest.put(username, lastRequest); userLastRequest.put(username, lastRequest);
} }
model.addAttribute("team", team); model.addAttribute("team", team);
model.addAttribute("teamUsers", teamUsers);
model.addAttribute("availableUsers", availableUsers);
model.addAttribute("userLastRequest", userLastRequest); model.addAttribute("userLastRequest", userLastRequest);
return "enterprise/team-details"; return "accounts/team-details";
} }
} }

View File

@ -27,9 +27,12 @@ public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE u.team IS NULL") @Query("SELECT u FROM User u WHERE u.team IS NULL")
List<User> findAllWithoutTeam(); List<User> findAllWithoutTeam();
@Query("SELECT u FROM User u LEFT JOIN FETCH u.team") @Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team")
List<User> findAllWithTeam(); List<User> findAllWithTeam();
@Query("SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId")
List<User> findAllByTeamId(@Param("teamId") Long teamId);
long countByTeam(Team team); long countByTeam(Team team);
List<User> findAllByTeam(Team team); List<User> findAllByTeam(Team team);

View File

@ -5,16 +5,19 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
@Repository @Repository
public interface TeamRepository extends JpaRepository<Team, Long> { public interface TeamRepository extends JpaRepository<Team, Long> {
Optional<Team> findByName(String name); Optional<Team> findByName(String name);
@Query("SELECT t FROM Team t LEFT JOIN FETCH t.users") @Query("SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) " +
List<Team> findAllWithUsers(); "FROM Team t LEFT JOIN t.users u GROUP BY t.id, t.name")
List<TeamWithUserCountDTO> findAllTeamsWithUserCount();
boolean existsByNameIgnoreCase(String name); boolean existsByNameIgnoreCase(String name);
} }

View File

@ -330,6 +330,32 @@
gap: 0.75rem; 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 { .data-modal-body {
padding: 1.5rem; padding: 1.5rem;
} }

View File

@ -0,0 +1,196 @@
<!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">Total Members:</div>
<div class="data-stat-value" th:text="${teamUsers.size()}">1</div>
</div>
</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">Members</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : 'Pro feature'" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th>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'}) : '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>
Enabled
</span>
<span th:unless="${user.enabled}" class="data-status data-status-danger">
<span class="material-symbols-rounded">person_off</span>
Disabled
</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">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="#{selectFillter}">-- 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,4 +1,3 @@
<!-- templates/enterprise/teams.html -->
<!DOCTYPE html> <!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org"> <html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head> <head>
@ -23,6 +22,14 @@
</div> </div>
<div class="data-body"> <div class="data-body">
<!-- Back Button -->
<div class="data-actions data-actions-start">
<a 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>
<!-- Create New Team Button --> <!-- Create New Team Button -->
<div class="data-actions"> <div class="data-actions">
<a href="#" <a href="#"
@ -47,18 +54,19 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="team : ${teams}"> <!-- Try approach 1 - DTO projection -->
<td th:text="${team.name}"></td> <tr th:each="teamDto : ${teamsWithCounts}">
<td th:text="${team.users.size()}"></td> <td th:text="${teamDto.name}"></td>
<td th:text="${@runningProOrHigher} ? (${teamLastRequest[team.id] != null ? #dates.format(teamLastRequest[team.id], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : 'hidden'"></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'}) : 'hidden'"></td>
<td> <td>
<div class="data-action-cell"> <div class="data-action-cell">
<a th:href="@{'/teams/' + ${team.id}}" class="data-btn data-btn-secondary data-btn-sm" th:title="#{adminUserSettings.viewTeam}"> <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> <span class="material-symbols-rounded">search</span> <span th:text="#{view}">View</span>
</a> </a>
<form th:action="@{'/api/v1/team/delete'}" method="post" style="display:inline-block" <form th:action="@{'/api/v1/team/delete'}" method="post" style="display:inline-block"
onsubmit="return confirmDeleteTeam()"> onsubmit="return confirmDeleteTeam()">
<input type="hidden" name="teamId" th:value="${team.id}" /> <input type="hidden" name="teamId" th:value="${teamDto.id}" />
<button type="submit" class="data-btn data-btn-danger data-btn-sm" <button type="submit" class="data-btn data-btn-danger data-btn-sm"
th:disabled="${!@runningProOrHigher}" th:disabled="${!@runningProOrHigher}"
th:classappend="${!@runningProOrHigher} ? 'disabled' : ''" th:classappend="${!@runningProOrHigher} ? 'disabled' : ''"

View File

@ -1,95 +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">Team ID:</div>
<div class="data-stat-value" th:text="${team.id}">1</div>
</div>
<div class="data-stat-card">
<div class="data-stat-label">Total Members:</div>
<div class="data-stat-value" th:text="${team.users.size()}">1</div>
</div>
</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>Back to Teams</span>
</a>
</div>
<div class="data-section-title">Members</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : 'Pro feature'" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${team.users}">
<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'}) : '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>
Enabled
</span>
<span th:unless="${user.enabled}" class="data-status data-status-danger">
<span class="material-symbols-rounded">person_off</span>
Disabled
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state for when there are no team members -->
<div th:if="${team.users.empty}" class="data-empty">
<span class="material-symbols-rounded data-empty-icon">person_off</span>
<p class="data-empty-text">This team has no members yet.</p>
<a th:href="@{'/admin/users'}" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">person_add</span>
<span>Add Users to Team</span>
</a>
</div>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>

View File

@ -1,43 +1,3 @@
account.adminTitle=Administrator Tools
account.adminNotif=You have admin privileges. Access system settings and user management.
view=View
cancel=Cancel
adminUserSettings.teams=View/Edit Teams
adminUserSettings.team=Team
adminUserSettings.manageTeams=Manage Teams
adminUserSettings.createTeam=Create Team
adminUserSettings.viewTeam=View Team
adminUserSettings.deleteTeam=Delete Team
adminUserSettings.teamName=Team Name
adminUserSettings.teamExists=Team already exists
adminUserSettings.teamCreated=Team created successfully
adminUserSettings.teamChanged=User's team was updated
adminUserSettings.totalMembers=Total Members
teamCreated=Team created successfully
teamExists=A team with that name already exists
teamNameExists=Another team with that name already exists
teamNotFound=Team not found
teamDeleted=Team deleted
teamHasUsers=Cannot delete a team with users assigned
teamRenamed=Team renamed successfully
merge.generateToc=Generate table of contents in the merged file?
# Table of Contents Feature
home.editTableOfContents.title=Edit Table of Contents
home.editTableOfContents.desc=Add or edit bookmarks and table of contents in PDF documents
editTableOfContents.tags=bookmarks,toc,navigation,index,table of contents,chapters,sections,outline
editTableOfContents.title=Edit Table of Contents
editTableOfContents.header=Add or Edit PDF Table of Contents
editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to append to existing)
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
editTableOfContents.submit=Apply Table of Contents
########### ###########
# Generic # # Generic #
########### ###########
@ -259,6 +219,12 @@ addToDoc=Add to Document
reset=Reset reset=Reset
apply=Apply apply=Apply
noFileSelected=No file selected. Please upload one. noFileSelected=No file selected. Please upload one.
view=View
cancel=Cancel
back.toSettings=Back to Settings
back.toHome=Back to Home
back.toAdmin=Back to Admin
legal.privacy=Privacy Policy legal.privacy=Privacy Policy
legal.terms=Terms and Conditions legal.terms=Terms and Conditions
@ -380,6 +346,8 @@ account.property=Property
account.webBrowserSettings=Web Browser Setting account.webBrowserSettings=Web Browser Setting
account.syncToBrowser=Sync Account -> Browser account.syncToBrowser=Sync Account -> Browser
account.syncToAccount=Sync Account <- Browser account.syncToAccount=Sync Account <- Browser
account.adminTitle=Administrator Tools
account.adminNotif=You have admin privileges. Access system settings and user management.
adminUserSettings.title=User Control Settings adminUserSettings.title=User Control Settings
@ -411,6 +379,39 @@ adminUserSettings.disabledUsers=Disabled Users:
adminUserSettings.totalUsers=Total Users: adminUserSettings.totalUsers=Total Users:
adminUserSettings.lastRequest=Last Request adminUserSettings.lastRequest=Last Request
adminUserSettings.usage=View Usage adminUserSettings.usage=View Usage
adminUserSettings.teams=View/Edit Teams
adminUserSettings.team=Team
adminUserSettings.manageTeams=Manage Teams
adminUserSettings.createTeam=Create Team
adminUserSettings.viewTeam=View Team
adminUserSettings.deleteTeam=Delete Team
adminUserSettings.teamName=Team Name
adminUserSettings.teamExists=Team already exists
adminUserSettings.teamCreated=Team created successfully
adminUserSettings.teamChanged=User's team was updated
adminUserSettings.totalMembers=Total Members
adminUserSettings.confirmDeleteTeam=Are you sure you want to delete this team?
teamCreated=Team created successfully
teamExists=A team with that name already exists
teamNameExists=Another team with that name already exists
teamNotFound=Team not found
teamDeleted=Team deleted
teamHasUsers=Cannot delete a team with users assigned
teamRenamed=Team renamed successfully
# Team user management
team.addUser=Add User to Team
team.selectUser=Select User
team.warning.moveUser=Warning: This will move the user from "{0}" team to "{1}" team. Are you sure?
team.confirm.moveUser=Are you sure you want to move this user from "{0}" team to "{1}" team?
team.userAdded=User successfully added to team
team.back=Back to Teams
team.internal=Internal Team
team.internalTeamNotAccessible=The Internal team is a system team and cannot be accessed
team.cannotMoveInternalUsers=Users in the Internal team cannot be moved to other teams
endpointStatistics.title=Endpoint Statistics endpointStatistics.title=Endpoint Statistics
endpointStatistics.header=Endpoint Statistics endpointStatistics.header=Endpoint Statistics
@ -1199,6 +1200,7 @@ merge.header=Merge multiple PDFs (2+)
merge.sortByName=Sort by name merge.sortByName=Sort by name
merge.sortByDate=Sort by date merge.sortByDate=Sort by date
merge.removeCertSign=Remove digital signature in the merged file? merge.removeCertSign=Remove digital signature in the merged file?
merge.generateToc=Generate table of contents in the merged file?
merge.submit=Merge merge.submit=Merge
@ -1683,3 +1685,22 @@ fakeScan.blur=Blur
fakeScan.noise=Noise fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper) fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI) fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
home.editTableOfContents.title=Edit Table of Contents
home.editTableOfContents.desc=Add or edit bookmarks and table of contents in PDF documents
editTableOfContents.tags=bookmarks,toc,navigation,index,table of contents,chapters,sections,outline
editTableOfContents.title=Edit Table of Contents
editTableOfContents.header=Add or Edit PDF Table of Contents
editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to append to existing)
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
editTableOfContents.submit=Apply Table of Contents