add message

This commit is contained in:
Ludy87 2025-06-16 19:01:33 +02:00
parent 89580387a2
commit 5b5add35e2
No known key found for this signature in database
GPG Key ID: 92696155E0220F94
7 changed files with 137 additions and 73 deletions

View File

@ -2,6 +2,10 @@ package stirling.software.proprietary.security.config;
import static stirling.software.common.util.ProviderUtils.validateProvider; import static stirling.software.common.util.ProviderUtils.validateProvider;
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 java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Date; import java.util.Date;
@ -10,7 +14,7 @@ import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@ -19,16 +23,6 @@ import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
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;
import stirling.software.common.model.ApplicationProperties.Security; import stirling.software.common.model.ApplicationProperties.Security;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
@ -239,7 +233,8 @@ public class AccountWebController {
} }
// Also check if user is part of the Internal team // Also check if user is part of the Internal team
if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
shouldRemove = true; shouldRemove = true;
} }
@ -336,6 +331,9 @@ public class AccountWebController {
case "userNotFound" -> "userNotFoundMessage"; case "userNotFound" -> "userNotFoundMessage";
case "downgradeCurrentUser" -> "downgradeCurrentUserMessage"; case "downgradeCurrentUser" -> "downgradeCurrentUserMessage";
case "disabledCurrentUser" -> "disabledCurrentUserMessage"; case "disabledCurrentUser" -> "disabledCurrentUserMessage";
case "cannotMoveInternalUsers" -> "team.cannotMoveInternalUsers";
case "internalTeamNotAccessible" -> "team.internalTeamNotAccessible";
case "invalidRole" -> "invalidRoleMessage";
default -> messageType; default -> messageType;
}; };
model.addAttribute("changeMessage", changeMessage); model.addAttribute("changeMessage", changeMessage);
@ -351,10 +349,16 @@ public class AccountWebController {
model.addAttribute("disabledUsers", disabledUsers); model.addAttribute("disabledUsers", disabledUsers);
// Get all teams but filter out the Internal team // Get all teams but filter out the Internal team
List<Team> allTeams = teamRepository.findAll() List<Team> allTeams =
.stream() teamRepository.findAll().stream()
.filter(team -> !team.getName().equals(stirling.software.proprietary.security.service.TeamService.INTERNAL_TEAM_NAME)) .filter(
.toList(); 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

@ -1,19 +1,14 @@
package stirling.software.proprietary.security.controller.api; package stirling.software.proprietary.security.controller.api;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView; import org.springframework.web.servlet.view.RedirectView;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
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;
@ -36,12 +31,12 @@ public class TeamController {
@PostMapping("/create") @PostMapping("/create")
public RedirectView createTeam(@RequestParam("name") String name) { public RedirectView createTeam(@RequestParam("name") String name) {
if (teamRepository.existsByNameIgnoreCase(name)) { if (teamRepository.existsByNameIgnoreCase(name)) {
return new RedirectView("/adminSettings?messageType=teamExists"); return new RedirectView("/teams?messageType=teamExists");
} }
Team team = new Team(); Team team = new Team();
team.setName(name); team.setName(name);
teamRepository.save(team); teamRepository.save(team);
return new RedirectView("/adminSettings?messageType=teamCreated"); return new RedirectView("/teams?messageType=teamCreated");
} }
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
@ -50,21 +45,21 @@ public class TeamController {
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) { @RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
Optional<Team> existing = teamRepository.findById(teamId); Optional<Team> existing = teamRepository.findById(teamId);
if (existing.isEmpty()) { if (existing.isEmpty()) {
return new RedirectView("/adminSettings?messageType=teamNotFound"); return new RedirectView("/teams?messageType=teamNotFound");
} }
if (teamRepository.existsByNameIgnoreCase(newName)) { if (teamRepository.existsByNameIgnoreCase(newName)) {
return new RedirectView("/adminSettings?messageType=teamNameExists"); return new RedirectView("/teams?messageType=teamNameExists");
} }
Team team = existing.get(); Team team = existing.get();
// Prevent renaming the Internal team // Prevent renaming the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible"); return new RedirectView("/teams?messageType=internalTeamNotAccessible");
} }
team.setName(newName); team.setName(newName);
teamRepository.save(team); teamRepository.save(team);
return new RedirectView("/adminSettings?messageType=teamRenamed"); return new RedirectView("/teams?messageType=teamRenamed");
} }
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
@ -73,35 +68,36 @@ public class TeamController {
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) { public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
Optional<Team> teamOpt = teamRepository.findById(teamId); Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) { if (teamOpt.isEmpty()) {
return new RedirectView("/adminSettings?messageType=teamNotFound"); return new RedirectView("/teams?messageType=teamNotFound");
} }
Team team = teamOpt.get(); Team team = teamOpt.get();
// Prevent deleting the Internal team // Prevent deleting the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible"); return new RedirectView("/teams?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("/teams?messageType=teamHasUsers");
} }
teamRepository.delete(team); teamRepository.delete(team);
return new RedirectView("/adminSettings?messageType=teamDeleted"); return new RedirectView("/teams?messageType=teamDeleted");
} }
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/addUser") @PostMapping("/addUser")
@Transactional @Transactional
public RedirectView addUserToTeam( public RedirectView addUserToTeam(
@RequestParam("teamId") Long teamId, @RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
@RequestParam("userId") Long userId) {
// Find the team // Find the team
Team team = teamRepository.findById(teamId) Team team =
.orElseThrow(() -> new RuntimeException("Team not found")); teamRepository
.findById(teamId)
.orElseThrow(() -> new RuntimeException("Team not found"));
// Prevent adding users to the Internal team // Prevent adding users to the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
@ -109,11 +105,14 @@ public class TeamController {
} }
// Find the user // Find the user
User user = userRepository.findById(userId) User user =
.orElseThrow(() -> new RuntimeException("User not found")); userRepository
.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
// Check if user is in the Internal team - prevent moving them // Check if user is in the Internal team - prevent moving them
if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers"); return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
} }

View File

@ -1,20 +1,17 @@
package stirling.software.proprietary.security.controller.web; package stirling.software.proprietary.security.controller.web;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.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.model.dto.TeamWithUserCountDTO;
import stirling.software.proprietary.security.database.repository.SessionRepository; import stirling.software.proprietary.security.database.repository.SessionRepository;
@ -35,14 +32,15 @@ public class TeamWebController {
@GetMapping @GetMapping
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
public String listTeams(Model model) { public String listTeams(HttpServletRequest request, Model model) {
// Get teams with user counts using a DTO projection // Get teams with user counts using a DTO projection
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount(); List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
// Filter out the Internal team // Filter out the Internal team
List<TeamWithUserCountDTO> teamsWithCounts = allTeamsWithCounts.stream() List<TeamWithUserCountDTO> teamsWithCounts =
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) allTeamsWithCounts.stream()
.toList(); .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();
@ -55,6 +53,27 @@ public class TeamWebController {
teamLastRequest.put(teamId, lastActivity); 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 // Add data to the model
model.addAttribute("teamsWithCounts", teamsWithCounts); model.addAttribute("teamsWithCounts", teamsWithCounts);
model.addAttribute("teamLastRequest", teamLastRequest); model.addAttribute("teamLastRequest", teamLastRequest);
@ -64,10 +83,13 @@ public class TeamWebController {
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
public String viewTeamDetails(@PathVariable("id") Long id, Model model) { public String viewTeamDetails(
HttpServletRequest request, @PathVariable("id") Long id, Model model) {
// Get the team // Get the team
Team team = teamRepository.findById(id) Team team =
.orElseThrow(() -> new RuntimeException("Team not found")); teamRepository
.findById(id)
.orElseThrow(() -> new RuntimeException("Team not found"));
// Prevent access to Internal team // Prevent access to Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
@ -80,10 +102,19 @@ public class TeamWebController {
// Get all users not in this team for the Add User to Team dropdown // Get all users not in this team for the Add User to Team dropdown
// Exclude users that are in the Internal team // Exclude users that are in the Internal team
List<User> allUsers = userRepository.findAllWithTeam(); List<User> allUsers = userRepository.findAllWithTeam();
List<User> availableUsers = allUsers.stream() List<User> availableUsers =
.filter(user -> (user.getTeam() == null || !user.getTeam().getId().equals(id)) && allUsers.stream()
(user.getTeam() == null || !user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME))) .filter(
.toList(); 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);
@ -96,6 +127,13 @@ public class TeamWebController {
userLastRequest.put(username, lastRequest); 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("team", team);
model.addAttribute("teamUsers", teamUsers); model.addAttribute("teamUsers", teamUsers);
model.addAttribute("availableUsers", availableUsers); model.addAttribute("availableUsers", availableUsers);

View File

@ -32,6 +32,11 @@
</div> </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"> <div class="data-actions data-actions-start">
<a th:href="@{'/teams'}" class="data-btn data-btn-secondary"> <a th:href="@{'/teams'}" class="data-btn data-btn-secondary">
<span class="material-symbols-rounded">arrow_back</span> <span class="material-symbols-rounded">arrow_back</span>

View File

@ -29,12 +29,29 @@
<div class="data-body"> <div class="data-body">
<!-- Back Button --> <!-- Back Button -->
<div class="data-actions data-actions-start"> <div class="data-actions data-actions-start">
<a href="/adminSettings" class="data-btn data-btn-secondary"> <a th:href="@{'/adminSettings'}" class="data-btn data-btn-secondary">
<span class="material-symbols-rounded">arrow_back</span> <span class="material-symbols-rounded">arrow_back</span>
<span th:text="#{back.toSettings}">Back to Settings</span> <span th:text="#{back.toSettings}">Back to Settings</span>
</a> </a>
</div> </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 --> <!-- Create New Team Button -->
<div class="data-actions"> <div class="data-actions">
<a href="#" th:data-bs-toggle="${@runningProOrHigher} ? 'modal' : null" <a href="#" th:data-bs-toggle="${@runningProOrHigher} ? 'modal' : null"

View File

@ -200,6 +200,7 @@ disabledCurrentUserMessage=The current user cannot be disabled
downgradeCurrentUserLongMessage=Cannot downgrade current user's role. Hence, current user will not be shown. downgradeCurrentUserLongMessage=Cannot downgrade current user's role. Hence, current user will not be shown.
userAlreadyExistsOAuthMessage=The user already exists as an OAuth2 user. userAlreadyExistsOAuthMessage=The user already exists as an OAuth2 user.
userAlreadyExistsWebMessage=The user already exists as an web user. userAlreadyExistsWebMessage=The user already exists as an web user.
invalidRoleMessage=Invalid role.
error=Error error=Error
oops=Oops! oops=Oops!
help=Help help=Help

View File

@ -93,8 +93,8 @@
<span class="material-symbols-rounded">person_add</span> <span class="material-symbols-rounded">person_add</span>
<span th:text="#{adminUserSettings.addUser}">Add New User</span> <span th:text="#{adminUserSettings.addUser}">Add New User</span>
</button> </button>
<a href="/teams" class="data-btn data-btn-secondary" th:title="#{adminUserSettings.teams}"> <a th:href="@{'/teams'}" class="data-btn data-btn-secondary" th:title="#{adminUserSettings.teams}">
<span class="material-symbols-rounded">group</span> <span class="material-symbols-rounded">group</span>
<span th:text="#{adminUserSettings.teams}">Manage Teams</span> <span th:text="#{adminUserSettings.teams}">Manage Teams</span>
</a> </a>
@ -108,7 +108,7 @@
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span> <span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
</button> </button>
<a href="/usage" th:if="${@runningEE}" class="data-btn data-btn-secondary" th:title="#{adminUserSettings.usage}"> <a th:href="@{'/usage'}" th:if="${@runningEE}" class="data-btn data-btn-secondary" th:title="#{adminUserSettings.usage}">
<span class="material-symbols-rounded">analytics</span> <span class="material-symbols-rounded">analytics</span>
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span> <span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
</a> </a>
@ -120,27 +120,27 @@
<thead> <thead>
<tr> <tr>
<th scope="col">#</th> <th scope="col">#</th>
<th scope="col" th:title="#{username}" th:text="#{username}">Username</th> <th scope="col" th:title="#{username}" class="text-overflow" th:text="#{username}">Username</th>
<th scope="col" th:title="#{adminUserSettings.team}" th:text="#{adminUserSettings.team}">Team</th> <th scope="col" th:title="#{adminUserSettings.team}" class="text-overflow" th:text="#{adminUserSettings.team}">Team</th>
<th scope="col" th:title="#{adminUserSettings.role}" th:text="#{adminUserSettings.role}">Roles</th> <th scope="col" th:title="#{adminUserSettings.role}" class="text-overflow" th:text="#{adminUserSettings.role}">Roles</th>
<th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow" th:text="#{adminUserSettings.authenticated}">Authenticated</th> <th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow" th:text="#{adminUserSettings.authenticated}">Authenticated</th>
<th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th> <th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}">Actions</th> <th scope="col" th:title="#{adminUserSettings.actions}" class="text-overflow" th:text="#{adminUserSettings.actions}">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="user : ${users}"> <tr th:each="user : ${users}">
<td th:text="${user.id}"></td> <td th:text="${user.id}" th:title="${user.id}" class="text-overflow"></td>
<td th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td> <td th:text="${user.username}" th:title="${user.username}" class="text-overflow" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
<td th:text="${user.team != null ? user.team.name : '—'}"></td> <td th:text="${user.team != null ? user.team.name : '—'}" th:title="${user.team != null ? user.team.name : '—'}" class="text-overflow"></td>
<td> <td>
<span class="data-badge" style="background-color: var(--md-sys-color-secondary-container); color: var(--md-sys-color-secondary); padding: 0.25rem 0.5rem; border-radius: 1rem; font-size: 0.875rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <span class="data-badge" style="background-color: var(--md-sys-color-secondary-container); color: var(--md-sys-color-secondary); padding: 0.25rem 0.5rem; border-radius: 1rem; font-size: 0.875rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<span class="material-symbols-rounded" style="font-size: 1rem;">shield</span> <span class="material-symbols-rounded" style="font-size: 1rem;">shield</span>
<span th:text="#{${user.roleName}}">Role</span> <span th:text="#{${user.roleName}}" th:title="#{${user.roleName}}" class="text-overflow">Role</span>
</span> </span>
</td> </td>
<td th:text="${user.authenticationType}"></td> <td th:text="${user.authenticationType}" th:title="${user.authenticationType}"></td>
<td th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td> <td th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}" th:title="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td>
<td> <td>
<div class="data-action-cell"> <div class="data-action-cell">
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()" style="display: inline;"> <form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()" style="display: inline;">