mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-06 13:48:58 +02:00
team
This commit is contained in:
parent
e78fd00a70
commit
1471f80199
@ -1,6 +1,7 @@
|
|||||||
package stirling.software.SPDF.config.security;
|
package stirling.software.SPDF.config.security;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@ -13,6 +14,8 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.SPDF.config.interfaces.DatabaseInterface;
|
import stirling.software.SPDF.config.interfaces.DatabaseInterface;
|
||||||
import stirling.software.SPDF.model.ApplicationProperties;
|
import stirling.software.SPDF.model.ApplicationProperties;
|
||||||
import stirling.software.SPDF.model.Role;
|
import stirling.software.SPDF.model.Role;
|
||||||
|
import stirling.software.SPDF.model.Team;
|
||||||
|
import stirling.software.SPDF.model.User;
|
||||||
import stirling.software.SPDF.model.exception.UnsupportedProviderException;
|
import stirling.software.SPDF.model.exception.UnsupportedProviderException;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -21,7 +24,7 @@ import stirling.software.SPDF.model.exception.UnsupportedProviderException;
|
|||||||
public class InitialSecuritySetup {
|
public class InitialSecuritySetup {
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final TeamService teamService;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
private final DatabaseInterface databaseService;
|
private final DatabaseInterface databaseService;
|
||||||
@ -39,12 +42,26 @@ public class InitialSecuritySetup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userService.migrateOauth2ToSSO();
|
userService.migrateOauth2ToSSO();
|
||||||
|
assignUsersToDefaultTeamIfMissing();
|
||||||
initializeInternalApiUser();
|
initializeInternalApiUser();
|
||||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||||
log.error("Failed to initialize security setup.", e);
|
log.error("Failed to initialize security setup.", e);
|
||||||
System.exit(1);
|
System.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void assignUsersToDefaultTeamIfMissing() {
|
||||||
|
Team defaultTeam = teamService.getOrCreateDefaultTeam();
|
||||||
|
List<User> usersWithoutTeam = userService.getUsersWithoutTeam();
|
||||||
|
|
||||||
|
for (User user : usersWithoutTeam) {
|
||||||
|
user.setTeam(defaultTeam);
|
||||||
|
}
|
||||||
|
|
||||||
|
userService.saveAll(usersWithoutTeam); // batch save
|
||||||
|
log.info("Assigned {} user(s) without a team to the default team.", usersWithoutTeam.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private void initializeAdminUser() throws SQLException, UnsupportedProviderException {
|
private void initializeAdminUser() throws SQLException, UnsupportedProviderException {
|
||||||
String initialUsername =
|
String initialUsername =
|
||||||
@ -56,8 +73,8 @@ public class InitialSecuritySetup {
|
|||||||
&& initialPassword != null
|
&& initialPassword != null
|
||||||
&& !initialPassword.isEmpty()
|
&& !initialPassword.isEmpty()
|
||||||
&& userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) {
|
&& userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) {
|
||||||
|
Team team = teamService.getOrCreateDefaultTeam();
|
||||||
userService.saveUser(initialUsername, initialPassword, Role.ADMIN.getRoleId());
|
userService.saveUser(initialUsername, initialPassword,team, Role.ADMIN.getRoleId(), false);
|
||||||
log.info("Admin user created: {}", initialUsername);
|
log.info("Admin user created: {}", initialUsername);
|
||||||
} else {
|
} else {
|
||||||
createDefaultAdminUser();
|
createDefaultAdminUser();
|
||||||
@ -69,7 +86,8 @@ public class InitialSecuritySetup {
|
|||||||
String defaultPassword = "stirling";
|
String defaultPassword = "stirling";
|
||||||
|
|
||||||
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
|
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
|
||||||
userService.saveUser(defaultUsername, defaultPassword, Role.ADMIN.getRoleId(), true);
|
Team team = teamService.getOrCreateDefaultTeam();
|
||||||
|
userService.saveUser(defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true);
|
||||||
log.info("Default admin user created: {}", defaultUsername);
|
log.info("Default admin user created: {}", defaultUsername);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,10 +95,13 @@ public class InitialSecuritySetup {
|
|||||||
private void initializeInternalApiUser()
|
private void initializeInternalApiUser()
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
||||||
|
Team team = teamService.getOrCreateInternalTeam();
|
||||||
|
|
||||||
userService.saveUser(
|
userService.saveUser(
|
||||||
Role.INTERNAL_API_USER.getRoleId(),
|
Role.INTERNAL_API_USER.getRoleId(),
|
||||||
UUID.randomUUID().toString(),
|
UUID.randomUUID().toString(),
|
||||||
Role.INTERNAL_API_USER.getRoleId());
|
team,
|
||||||
|
Role.INTERNAL_API_USER.getRoleId(), false);
|
||||||
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
|
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
|
||||||
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
|
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
package stirling.software.SPDF.config.security;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import stirling.software.SPDF.model.Team;
|
||||||
|
import stirling.software.SPDF.repository.TeamRepository;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TeamService {
|
||||||
|
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
|
|
||||||
|
public static final String DEFAULT_TEAM_NAME = "Default";
|
||||||
|
public static final String INTERNAL_TEAM_NAME = "Internal";
|
||||||
|
public Team getOrCreateDefaultTeam() {
|
||||||
|
return teamRepository.findByName(DEFAULT_TEAM_NAME)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Team defaultTeam = new Team();
|
||||||
|
defaultTeam.setName(DEFAULT_TEAM_NAME);
|
||||||
|
return teamRepository.save(defaultTeam);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public Team getOrCreateInternalTeam() {
|
||||||
|
return teamRepository.findByName(INTERNAL_TEAM_NAME)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Team internalTeam = new Team();
|
||||||
|
internalTeam.setName(INTERNAL_TEAM_NAME);
|
||||||
|
return teamRepository.save(internalTeam);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -29,6 +29,7 @@ import stirling.software.SPDF.controller.api.pipeline.UserServiceInterface;
|
|||||||
import stirling.software.SPDF.model.*;
|
import stirling.software.SPDF.model.*;
|
||||||
import stirling.software.SPDF.model.exception.UnsupportedProviderException;
|
import stirling.software.SPDF.model.exception.UnsupportedProviderException;
|
||||||
import stirling.software.SPDF.repository.AuthorityRepository;
|
import stirling.software.SPDF.repository.AuthorityRepository;
|
||||||
|
import stirling.software.SPDF.repository.TeamRepository;
|
||||||
import stirling.software.SPDF.repository.UserRepository;
|
import stirling.software.SPDF.repository.UserRepository;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -37,7 +38,7 @@ import stirling.software.SPDF.repository.UserRepository;
|
|||||||
public class UserService implements UserServiceInterface {
|
public class UserService implements UserServiceInterface {
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
private final AuthorityRepository authorityRepository;
|
private final AuthorityRepository authorityRepository;
|
||||||
|
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
@ -152,10 +153,41 @@ public class UserService implements UserServiceInterface {
|
|||||||
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, AuthenticationType authenticationType)
|
public void saveUser(String username, AuthenticationType authenticationType,Long teamId)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
saveUser(username, authenticationType, Role.USER.getRoleId());
|
saveUser(username, authenticationType, teamId, Role.USER.getRoleId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public User saveUser(String username, AuthenticationType type)
|
||||||
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
|
|
||||||
|
if (!isUsernameValid(username)) {
|
||||||
|
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setAuthenticationType(type);
|
||||||
|
user.setEnabled(true);
|
||||||
|
user.setFirstLogin(true);
|
||||||
|
|
||||||
|
String defaultRole = Role.USER.getRoleId();
|
||||||
|
user.addAuthority(new Authority(defaultRole, user));
|
||||||
|
|
||||||
|
Team defaultTeam = teamRepository.findByName("Default")
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Team team = new Team();
|
||||||
|
team.setName("Default");
|
||||||
|
return teamRepository.save(team);
|
||||||
|
});
|
||||||
|
user.setTeam(defaultTeam);
|
||||||
|
userRepository.save(user);
|
||||||
|
databaseService.exportDatabase();
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private User saveUser(Optional<User> user, String apiKey) {
|
private User saveUser(Optional<User> user, String apiKey) {
|
||||||
if (user.isPresent()) {
|
if (user.isPresent()) {
|
||||||
@ -165,7 +197,11 @@ public class UserService implements UserServiceInterface {
|
|||||||
throw new UsernameNotFoundException("User not found");
|
throw new UsernameNotFoundException("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, AuthenticationType authenticationType, String role)
|
public User saveUser(User user) {
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public User saveUser(String username, AuthenticationType authenticationType,Team team, String role)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!isUsernameValid(username)) {
|
if (!isUsernameValid(username)) {
|
||||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||||
@ -175,12 +211,66 @@ public class UserService implements UserServiceInterface {
|
|||||||
user.setEnabled(true);
|
user.setEnabled(true);
|
||||||
user.setFirstLogin(false);
|
user.setFirstLogin(false);
|
||||||
user.addAuthority(new Authority(role, user));
|
user.addAuthority(new Authority(role, user));
|
||||||
|
user.setTeam(team);
|
||||||
user.setAuthenticationType(authenticationType);
|
user.setAuthenticationType(authenticationType);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
databaseService.exportDatabase();
|
databaseService.exportDatabase();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User saveUser(String username, AuthenticationType authenticationType,Long teamId, String role)
|
||||||
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
|
if (!isUsernameValid(username)) {
|
||||||
|
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||||
|
}
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setEnabled(true);
|
||||||
|
user.setFirstLogin(false);
|
||||||
|
user.addAuthority(new Authority(role, user));
|
||||||
|
Optional<Team> optTeam = teamRepository.findById(teamId);
|
||||||
|
if(!optTeam.isPresent()) {
|
||||||
|
throw new IllegalArgumentException("Team ID was invalid, team not present");
|
||||||
|
}
|
||||||
|
user.setTeam(optTeam.get());
|
||||||
|
user.setAuthenticationType(authenticationType);
|
||||||
|
userRepository.save(user);
|
||||||
|
databaseService.exportDatabase();
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, String password)
|
public User saveUser(String username, String password, Long teamId)
|
||||||
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
|
|
||||||
|
if (!isUsernameValid(username)) {
|
||||||
|
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch team or throw
|
||||||
|
Team team = teamRepository.findById(teamId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Invalid team ID: " + teamId));
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPassword(passwordEncoder.encode(password));
|
||||||
|
user.setEnabled(true);
|
||||||
|
user.setAuthenticationType(AuthenticationType.WEB);
|
||||||
|
user.setTeam(team);
|
||||||
|
user.setFirstLogin(false); // or true depending on your policy
|
||||||
|
|
||||||
|
// Assign default USER role
|
||||||
|
user.addAuthority(new Authority(Role.USER.getRoleId(), user));
|
||||||
|
|
||||||
|
// Save user
|
||||||
|
userRepository.save(user);
|
||||||
|
databaseService.exportDatabase();
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public User saveUser(String username, String password, Team team, String role, boolean firstLogin)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!isUsernameValid(username)) {
|
if (!isUsernameValid(username)) {
|
||||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||||
@ -188,14 +278,17 @@ public class UserService implements UserServiceInterface {
|
|||||||
User user = new User();
|
User user = new User();
|
||||||
user.setUsername(username);
|
user.setUsername(username);
|
||||||
user.setPassword(passwordEncoder.encode(password));
|
user.setPassword(passwordEncoder.encode(password));
|
||||||
|
user.addAuthority(new Authority(role, user));
|
||||||
user.setEnabled(true);
|
user.setEnabled(true);
|
||||||
|
user.setTeam(team);
|
||||||
user.setAuthenticationType(AuthenticationType.WEB);
|
user.setAuthenticationType(AuthenticationType.WEB);
|
||||||
user.addAuthority(new Authority(Role.USER.getRoleId(), user));
|
user.setFirstLogin(firstLogin);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
databaseService.exportDatabase();
|
databaseService.exportDatabase();
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, String password, String role, boolean firstLogin)
|
public User saveUser(String username, String password, Long teamId, String role, boolean firstLogin)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!isUsernameValid(username)) {
|
if (!isUsernameValid(username)) {
|
||||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||||
@ -209,14 +302,15 @@ public class UserService implements UserServiceInterface {
|
|||||||
user.setFirstLogin(firstLogin);
|
user.setFirstLogin(firstLogin);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
databaseService.exportDatabase();
|
databaseService.exportDatabase();
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, String password, String role)
|
public void saveUser(String username, String password, Long teamId, String role)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
saveUser(username, password, role, false);
|
saveUser(username, password, teamId , role, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, String password, boolean firstLogin, boolean enabled)
|
public void saveUser(String username, String password,Long teamId, boolean firstLogin, boolean enabled)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!isUsernameValid(username)) {
|
if (!isUsernameValid(username)) {
|
||||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||||
@ -336,6 +430,10 @@ public class UserService implements UserServiceInterface {
|
|||||||
databaseService.exportDatabase();
|
databaseService.exportDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void saveAll(List<User> users) {
|
||||||
|
userRepository.saveAll(users);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isPasswordCorrect(User user, String currentPassword) {
|
public boolean isPasswordCorrect(User user, String currentPassword) {
|
||||||
return passwordEncoder.matches(currentPassword, user.getPassword());
|
return passwordEncoder.matches(currentPassword, user.getPassword());
|
||||||
}
|
}
|
||||||
@ -460,7 +558,6 @@ public class UserService implements UserServiceInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public long getTotalUsersCount() {
|
public long getTotalUsersCount() {
|
||||||
// Count all users in the database
|
// Count all users in the database
|
||||||
long userCount = userRepository.count();
|
long userCount = userRepository.count();
|
||||||
@ -470,4 +567,10 @@ public class UserService implements UserServiceInterface {
|
|||||||
}
|
}
|
||||||
return userCount;
|
return userCount;
|
||||||
}
|
}
|
||||||
|
public List<User> getUsersWithoutTeam() {
|
||||||
|
return userRepository.findAllWithoutTeam();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -29,4 +29,21 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
|
|||||||
@Param("expired") boolean expired,
|
@Param("expired") boolean expired,
|
||||||
@Param("lastRequest") Date lastRequest,
|
@Param("lastRequest") Date lastRequest,
|
||||||
@Param("principalName") String principalName);
|
@Param("principalName") String principalName);
|
||||||
|
|
||||||
|
|
||||||
|
@Query("SELECT t.id as teamId, MAX(s.lastRequest) as lastActivity " +
|
||||||
|
"FROM Team t " +
|
||||||
|
"LEFT JOIN t.users u " +
|
||||||
|
"LEFT JOIN SessionEntity s ON u.username = s.principalName " +
|
||||||
|
"GROUP BY t.id")
|
||||||
|
List<Object[]> findLatestActivityByTeam();
|
||||||
|
|
||||||
|
@Query("SELECT u.username as username, MAX(s.lastRequest) as lastRequest " +
|
||||||
|
"FROM User u " +
|
||||||
|
"LEFT JOIN SessionEntity s ON u.username = s.principalName " +
|
||||||
|
"WHERE u.team.id = :teamId " +
|
||||||
|
"GROUP BY u.username")
|
||||||
|
List<Object[]> findLatestSessionByTeamId(@Param("teamId") Long teamId);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,79 @@
|
|||||||
|
package stirling.software.SPDF.controller.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
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.SPDF.model.Team;
|
||||||
|
import stirling.software.SPDF.repository.TeamRepository;
|
||||||
|
import stirling.software.SPDF.repository.UserRepository;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/api/v1/team")
|
||||||
|
@Tag(name = "Team", description = "Team Management APIs")
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TeamController {
|
||||||
|
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
|
@PostMapping("/create")
|
||||||
|
public RedirectView createTeam(@RequestParam("name") String name) {
|
||||||
|
if (teamRepository.existsByNameIgnoreCase(name)) {
|
||||||
|
return new RedirectView("/adminSettings?messageType=teamExists");
|
||||||
|
}
|
||||||
|
Team team = new Team();
|
||||||
|
team.setName(name);
|
||||||
|
teamRepository.save(team);
|
||||||
|
return new RedirectView("/adminSettings?messageType=teamCreated");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
|
@PostMapping("/rename")
|
||||||
|
public RedirectView renameTeam(@RequestParam("teamId") Long teamId,
|
||||||
|
@RequestParam("newName") String newName) {
|
||||||
|
Optional<Team> existing = teamRepository.findById(teamId);
|
||||||
|
if (existing.isEmpty()) {
|
||||||
|
return new RedirectView("/adminSettings?messageType=teamNotFound");
|
||||||
|
}
|
||||||
|
if (teamRepository.existsByNameIgnoreCase(newName)) {
|
||||||
|
return new RedirectView("/adminSettings?messageType=teamNameExists");
|
||||||
|
}
|
||||||
|
Team team = existing.get();
|
||||||
|
team.setName(newName);
|
||||||
|
teamRepository.save(team);
|
||||||
|
return new RedirectView("/adminSettings?messageType=teamRenamed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
|
@PostMapping("/delete")
|
||||||
|
@Transactional
|
||||||
|
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||||
|
Optional<Team> teamOpt = teamRepository.findById(teamId);
|
||||||
|
if (teamOpt.isEmpty()) {
|
||||||
|
return new RedirectView("/adminSettings?messageType=teamNotFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
Team team = teamOpt.get();
|
||||||
|
long memberCount = userRepository.countByTeam(team);
|
||||||
|
if (memberCount > 0) {
|
||||||
|
return new RedirectView("/adminSettings?messageType=teamHasUsers");
|
||||||
|
}
|
||||||
|
|
||||||
|
teamRepository.delete(team);
|
||||||
|
return new RedirectView("/adminSettings?messageType=teamDeleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
package stirling.software.SPDF.controller.api;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
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 org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import stirling.software.SPDF.config.security.session.SessionRepository;
|
||||||
|
import stirling.software.SPDF.model.Team;
|
||||||
|
import stirling.software.SPDF.model.User;
|
||||||
|
import stirling.software.SPDF.repository.TeamRepository;
|
||||||
|
import stirling.software.SPDF.repository.UserRepository;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/teams")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TeamWebController {
|
||||||
|
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final SessionRepository sessionRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
|
public String listTeams(Model model) {
|
||||||
|
// Get all teams with their users
|
||||||
|
List<Team> teams = teamRepository.findAllWithUsers();
|
||||||
|
|
||||||
|
// 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, Date> teamLastRequest = new HashMap<>();
|
||||||
|
for (Object[] result : teamActivities) {
|
||||||
|
// For JPQL query with aliases
|
||||||
|
Long teamId = (Long) result[0]; // teamId alias
|
||||||
|
Date lastActivity = (Date) result[1]; // lastActivity alias
|
||||||
|
|
||||||
|
teamLastRequest.put(teamId, lastActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
model.addAttribute("teams", teams);
|
||||||
|
model.addAttribute("teamLastRequest", teamLastRequest);
|
||||||
|
return "teams";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
|
public String viewTeamDetails(@PathVariable("id") Long id, Model model) {
|
||||||
|
// Get the team with its users
|
||||||
|
Team team = teamRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||||
|
|
||||||
|
List<User> members = userRepository.findAllByTeam(team);
|
||||||
|
team.setUsers(new HashSet<>(members));
|
||||||
|
|
||||||
|
// 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, Date> userLastRequest = new HashMap<>();
|
||||||
|
|
||||||
|
// Process results from JPQL query
|
||||||
|
for (Object[] result : userSessions) {
|
||||||
|
String username = (String) result[0]; // username alias
|
||||||
|
Date lastRequest = (Date) result[1]; // lastRequest alias
|
||||||
|
|
||||||
|
userLastRequest.put(username, lastRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
model.addAttribute("team", team);
|
||||||
|
model.addAttribute("userLastRequest", userLastRequest);
|
||||||
|
return "team-details";
|
||||||
|
}
|
||||||
|
}
|
@ -36,9 +36,11 @@ import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
|
|||||||
import stirling.software.SPDF.model.ApplicationProperties;
|
import stirling.software.SPDF.model.ApplicationProperties;
|
||||||
import stirling.software.SPDF.model.AuthenticationType;
|
import stirling.software.SPDF.model.AuthenticationType;
|
||||||
import stirling.software.SPDF.model.Role;
|
import stirling.software.SPDF.model.Role;
|
||||||
|
import stirling.software.SPDF.model.Team;
|
||||||
import stirling.software.SPDF.model.User;
|
import stirling.software.SPDF.model.User;
|
||||||
import stirling.software.SPDF.model.api.user.UsernameAndPass;
|
import stirling.software.SPDF.model.api.user.UsernameAndPass;
|
||||||
import stirling.software.SPDF.model.exception.UnsupportedProviderException;
|
import stirling.software.SPDF.model.exception.UnsupportedProviderException;
|
||||||
|
import stirling.software.SPDF.repository.TeamRepository;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@Tag(name = "User", description = "User APIs")
|
@Tag(name = "User", description = "User APIs")
|
||||||
@ -51,22 +53,8 @@ public class UserController {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final SessionPersistentRegistry sessionRegistry;
|
private final SessionPersistentRegistry sessionRegistry;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
|
|
||||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
|
||||||
@PostMapping("/register")
|
|
||||||
public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
|
|
||||||
throws SQLException, UnsupportedProviderException {
|
|
||||||
if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) {
|
|
||||||
model.addAttribute("error", "Username already exists");
|
|
||||||
return "register";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
userService.saveUser(requestModel.getUsername(), requestModel.getPassword());
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return "redirect:/login?messageType=invalidUsername";
|
|
||||||
}
|
|
||||||
return "redirect:/login?registered=true";
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||||
@PostMapping("/change-username")
|
@PostMapping("/change-username")
|
||||||
@ -187,6 +175,7 @@ public class UserController {
|
|||||||
public RedirectView saveUser(
|
public RedirectView saveUser(
|
||||||
@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 = "teamId", required = false) Long teamId,
|
||||||
@RequestParam(name = "role") String role,
|
@RequestParam(name = "role") String role,
|
||||||
@RequestParam(name = "authType") String authType,
|
@RequestParam(name = "authType") String authType,
|
||||||
@RequestParam(name = "forceChange", required = false, defaultValue = "false")
|
@RequestParam(name = "forceChange", required = false, defaultValue = "false")
|
||||||
@ -221,14 +210,21 @@ 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);
|
||||||
}
|
}
|
||||||
|
Optional<Team> team = teamId != null ? teamRepository.findById(teamId) : Optional.empty();
|
||||||
|
User newUser;
|
||||||
|
|
||||||
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
||||||
userService.saveUser(username, AuthenticationType.SSO, role);
|
newUser = userService.saveUser(username, AuthenticationType.SSO, teamId,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, role, forceChange);
|
newUser = userService.saveUser(username, password, teamId, role, forceChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
team.ifPresent(newUser::setTeam);
|
||||||
|
userService.saveUser(newUser); // Persist with team
|
||||||
|
|
||||||
return new RedirectView(
|
return new RedirectView(
|
||||||
"/adminSettings", // Redirect to account page after adding the user
|
"/adminSettings", // Redirect to account page after adding the user
|
||||||
true);
|
true);
|
||||||
@ -374,4 +370,20 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
return ResponseEntity.ok(apiKey);
|
return ResponseEntity.ok(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/changeTeam")
|
||||||
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
|
public String changeUserTeam(@RequestParam String username, @RequestParam Long teamId) {
|
||||||
|
Optional<User> user = userService.findByUsernameIgnoreCase(username);
|
||||||
|
Optional<Team> team = teamRepository.findById(teamId);
|
||||||
|
if (user.isPresent() && team.isPresent()) {
|
||||||
|
user.get().setTeam(team.get());
|
||||||
|
userService.saveUser(user.get());
|
||||||
|
return "redirect:/adminSettings?messageType=teamChanged";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "redirect:/adminSettings?messageType=userNotFound";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import java.time.Instant;
|
|||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -19,6 +20,7 @@ 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 org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@ -39,10 +41,12 @@ import stirling.software.SPDF.model.ApplicationProperties.Security.SAML2;
|
|||||||
import stirling.software.SPDF.model.Authority;
|
import stirling.software.SPDF.model.Authority;
|
||||||
import stirling.software.SPDF.model.Role;
|
import stirling.software.SPDF.model.Role;
|
||||||
import stirling.software.SPDF.model.SessionEntity;
|
import stirling.software.SPDF.model.SessionEntity;
|
||||||
|
import stirling.software.SPDF.model.Team;
|
||||||
import stirling.software.SPDF.model.User;
|
import stirling.software.SPDF.model.User;
|
||||||
import stirling.software.SPDF.model.provider.GitHubProvider;
|
import stirling.software.SPDF.model.provider.GitHubProvider;
|
||||||
import stirling.software.SPDF.model.provider.GoogleProvider;
|
import stirling.software.SPDF.model.provider.GoogleProvider;
|
||||||
import stirling.software.SPDF.model.provider.KeycloakProvider;
|
import stirling.software.SPDF.model.provider.KeycloakProvider;
|
||||||
|
import stirling.software.SPDF.repository.TeamRepository;
|
||||||
import stirling.software.SPDF.repository.UserRepository;
|
import stirling.software.SPDF.repository.UserRepository;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@ -58,15 +62,20 @@ public class AccountWebController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final boolean runningEE;
|
private final boolean runningEE;
|
||||||
|
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
|
|
||||||
public AccountWebController(
|
public AccountWebController(
|
||||||
ApplicationProperties applicationProperties,
|
ApplicationProperties applicationProperties,
|
||||||
SessionPersistentRegistry sessionPersistentRegistry,
|
SessionPersistentRegistry sessionPersistentRegistry,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
|
TeamRepository teamRepository,
|
||||||
@Qualifier("runningEE") boolean runningEE) {
|
@Qualifier("runningEE") boolean runningEE) {
|
||||||
this.applicationProperties = applicationProperties;
|
this.applicationProperties = applicationProperties;
|
||||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.runningEE = runningEE;
|
this.runningEE = runningEE;
|
||||||
|
this.teamRepository=teamRepository;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/login")
|
@GetMapping("/login")
|
||||||
@ -210,7 +219,7 @@ public class AccountWebController {
|
|||||||
@GetMapping("/adminSettings")
|
@GetMapping("/adminSettings")
|
||||||
public String showAddUserForm(
|
public String showAddUserForm(
|
||||||
HttpServletRequest request, Model model, Authentication authentication) {
|
HttpServletRequest request, Model model, Authentication authentication) {
|
||||||
List<User> allUsers = userRepository.findAll();
|
List<User> allUsers = userRepository.findAllWithTeam();
|
||||||
Iterator<User> iterator = allUsers.iterator();
|
Iterator<User> iterator = allUsers.iterator();
|
||||||
Map<String, String> roleDetails = Role.getAllRoleDetails();
|
Map<String, String> roleDetails = Role.getAllRoleDetails();
|
||||||
// Map to store session information and user activity status
|
// Map to store session information and user activity status
|
||||||
@ -321,6 +330,10 @@ public class AccountWebController {
|
|||||||
};
|
};
|
||||||
model.addAttribute("changeMessage", changeMessage);
|
model.addAttribute("changeMessage", changeMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Team> allTeams = teamRepository.findAll();
|
||||||
|
model.addAttribute("teams", allTeams);
|
||||||
|
|
||||||
|
|
||||||
model.addAttribute("users", sortedUsers);
|
model.addAttribute("users", sortedUsers);
|
||||||
model.addAttribute("currentUsername", authentication.getName());
|
model.addAttribute("currentUsername", authentication.getName());
|
||||||
@ -444,5 +457,5 @@ public class AccountWebController {
|
|||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
}
|
}
|
||||||
return "change-creds";
|
return "change-creds";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
41
src/main/java/stirling/software/SPDF/model/Team.java
Normal file
41
src/main/java/stirling/software/SPDF/model/Team.java
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package stirling.software.SPDF.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "teams")
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||||
|
@ToString(onlyExplicitlyIncluded = true)
|
||||||
|
public class Team implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "team_id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "name", unique = true, nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private Set<User> users = new HashSet<>();
|
||||||
|
|
||||||
|
public void addUser(User user) {
|
||||||
|
users.add(user);
|
||||||
|
user.setTeam(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeUser(User user) {
|
||||||
|
users.remove(user);
|
||||||
|
user.setTeam(null);
|
||||||
|
}
|
||||||
|
}
|
@ -55,6 +55,10 @@ public class User implements Serializable {
|
|||||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||||
private Set<Authority> authorities = new HashSet<>();
|
private Set<Authority> authorities = new HashSet<>();
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "team_id")
|
||||||
|
private Team team;
|
||||||
|
|
||||||
@ElementCollection
|
@ElementCollection
|
||||||
@MapKeyColumn(name = "setting_key")
|
@MapKeyColumn(name = "setting_key")
|
||||||
@Lob
|
@Lob
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
package stirling.software.SPDF.repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import stirling.software.SPDF.model.Team;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TeamRepository extends JpaRepository<Team, Long> {
|
||||||
|
Optional<Team> findByName(String name);
|
||||||
|
|
||||||
|
@Query("SELECT t FROM Team t LEFT JOIN FETCH t.users")
|
||||||
|
List<Team> findAllWithUsers();
|
||||||
|
|
||||||
|
boolean existsByNameIgnoreCase(String name);
|
||||||
|
}
|
@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import stirling.software.SPDF.model.Team;
|
||||||
import stirling.software.SPDF.model.User;
|
import stirling.software.SPDF.model.User;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@ -22,4 +23,14 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
Optional<User> findByApiKey(String apiKey);
|
Optional<User> findByApiKey(String apiKey);
|
||||||
|
|
||||||
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
|
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
|
||||||
|
|
||||||
|
@Query("SELECT u FROM User u WHERE u.team IS NULL")
|
||||||
|
List<User> findAllWithoutTeam();
|
||||||
|
|
||||||
|
@Query("SELECT u FROM User u LEFT JOIN FETCH u.team")
|
||||||
|
List<User> findAllWithTeam();
|
||||||
|
|
||||||
|
long countByTeam(Team team);
|
||||||
|
|
||||||
|
List<User> findAllByTeam(Team team);
|
||||||
}
|
}
|
||||||
|
@ -87,6 +87,8 @@ 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
|
||||||
|
|
||||||
legal.privacy=Privacy Policy
|
legal.privacy=Privacy Policy
|
||||||
legal.terms=Terms and Conditions
|
legal.terms=Terms and Conditions
|
||||||
@ -207,6 +209,9 @@ 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
|
||||||
@ -227,7 +232,7 @@ adminUserSettings.webOnlyUser=Web Only User
|
|||||||
adminUserSettings.demoUser=Demo User (No custom settings)
|
adminUserSettings.demoUser=Demo User (No custom settings)
|
||||||
adminUserSettings.internalApiUser=Internal API User
|
adminUserSettings.internalApiUser=Internal API User
|
||||||
adminUserSettings.forceChange=Force user to change password on login
|
adminUserSettings.forceChange=Force user to change password on login
|
||||||
adminUserSettings.submit=Save User
|
adminUserSettings.submit=Save
|
||||||
adminUserSettings.changeUserRole=Change User's Role
|
adminUserSettings.changeUserRole=Change User's Role
|
||||||
adminUserSettings.authenticated=Authenticated
|
adminUserSettings.authenticated=Authenticated
|
||||||
adminUserSettings.editOwnProfil=Edit own profile
|
adminUserSettings.editOwnProfil=Edit own profile
|
||||||
@ -239,6 +244,26 @@ 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.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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
endpointStatistics.title=Endpoint Statistics
|
endpointStatistics.title=Endpoint Statistics
|
||||||
endpointStatistics.header=Endpoint Statistics
|
endpointStatistics.header=Endpoint Statistics
|
||||||
endpointStatistics.top10=Top 10
|
endpointStatistics.top10=Top 10
|
||||||
|
361
src/main/resources/static/css/modern-tables.css
Normal file
361
src/main/resources/static/css/modern-tables.css
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
<!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>
|
||||||
<th:block th:insert="~{fragments/common :: head(title=#{account.title})}"></th:block>
|
<th:block th:insert="~{fragments/common :: head(title=#{account.title})}"></th:block>
|
||||||
|
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@ -9,353 +10,457 @@
|
|||||||
<div id="page-container">
|
<div id="page-container">
|
||||||
<div id="content-wrap">
|
<div id="content-wrap">
|
||||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||||
<br><br>
|
|
||||||
<div class="container">
|
<div class="data-container">
|
||||||
<div class="row justify-content-center">
|
<div class="data-panel">
|
||||||
<div class="col-md-9 bg-card">
|
<div class="data-header">
|
||||||
<div class="tool-header">
|
<h1 class="data-title">
|
||||||
<span class="material-symbols-rounded tool-header-icon organize">settings_account_box</span>
|
<span class="data-icon">
|
||||||
<span class="tool-header-text" th:text="#{account.accountSettings}">User Settings</span>
|
<span class="material-symbols-rounded">settings_account_box</span>
|
||||||
</div>
|
</span>
|
||||||
|
<span th:text="#{account.accountSettings}">User Settings</span>
|
||||||
<!-- User Settings Title -->
|
</h1>
|
||||||
<th:block th:if="${messageType}">
|
</div>
|
||||||
<div class="alert alert-danger">
|
|
||||||
|
<div class="data-body">
|
||||||
|
<div th:if="${messageType}" class="alert alert-danger data-mb-3">
|
||||||
<span th:text="#{${messageType}}">Default message if not found</span>
|
<span th:text="#{${messageType}}">Default message if not found</span>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
|
||||||
|
<div th:if="${error}" class="alert alert-danger data-mb-3" role="alert">
|
||||||
<!-- At the top of the user settings -->
|
|
||||||
<h3 class="text-center"><span th:text="#{welcome} + ' ' + ${username}">User</span>!</h3>
|
|
||||||
<th:block th:if="${error}">
|
|
||||||
<div class="alert alert-danger" role="alert">
|
|
||||||
<span th:text="${error}">Error Message</span>
|
<span th:text="${error}">Error Message</span>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
|
||||||
|
<!-- Admin Settings Banner (for admins only) -->
|
||||||
<!-- Change Username Form -->
|
<div th:if="${role == 'ROLE_ADMIN'}" class="data-panel data-mb-3" style="background-color: var(--md-sys-color-secondary-container);">
|
||||||
|
<div class="data-body" style="display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; background-color: var(--md-sys-color-secondary-container);">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 2rem; color: var(--md-sys-color-secondary);">
|
||||||
|
admin_panel_settings
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="margin: 0; color: var(--md-sys-color-secondary);" th:text="#{account.adminTitle}">Administrator Tools</h4>
|
||||||
|
<p style="margin: 0.25rem 0 0 0; color: var(--md-sys-color-secondary);" th:text="#{account.adminNotif}">You have admin privileges. Access system settings and user management.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="data-btn" th:href="@{'/adminSettings'}" role="button" target="_blank"
|
||||||
|
style="background-color: var(--md-sys-color-secondary); color: var(--md-sys-color-on-secondary); display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.625rem 1.25rem; border-radius: 0.5rem; font-weight: 500; border: none; cursor: pointer; text-decoration: none;">
|
||||||
|
<span class="material-symbols-rounded">admin_panel_settings</span>
|
||||||
|
<span th:text="#{account.adminSettings}">Admin Settings</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Management Buttons -->
|
||||||
<th:block th:if="not ${oAuth2Login} or not ${saml2Login}">
|
<th:block th:if="not ${oAuth2Login} or not ${saml2Login}">
|
||||||
<h4 th:text="#{account.changeUsername}">Change Username?</h4>
|
<div class="data-section-title">Account Management</div>
|
||||||
<form id="formsavechangeusername" class="bg-card mt-4 mb-4" th:action="@{'/api/v1/user/change-username'}" method="post">
|
<div class="data-actions data-actions-start data-mb-3">
|
||||||
<div class="mb-3">
|
<button class="data-btn data-btn-primary" data-bs-toggle="modal" data-bs-target="#changeUsernameModal">
|
||||||
<label for="newUsername" th:text="#{account.newUsername}">Change Username</label>
|
<span class="material-symbols-rounded">edit</span>
|
||||||
<input type="text" class="form-control" name="newUsername" id="newUsername" th:placeholder="#{account.newUsername}">
|
<span th:text="#{account.changeUsername}">Change Username</span>
|
||||||
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
</button>
|
||||||
|
<button class="data-btn data-btn-primary" data-bs-toggle="modal" data-bs-target="#changePasswordModal">
|
||||||
|
<span class="material-symbols-rounded">key</span>
|
||||||
|
<span th:text="#{account.changePassword}">Change Password</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
|
||||||
<label for="currentPasswordChangeUsername" th:text="#{password}">Password</label>
|
|
||||||
<input type="password" class="form-control" name="currentPasswordChangeUsername" id="currentPasswordChangeUsername" th:placeholder="#{password}">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<button type="submit" class="btn btn-primary" th:text="#{account.changeUsername}">Change Username</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<!-- Change Password Form -->
|
<!-- API Key Section -->
|
||||||
<th:block th:if="not ${oAuth2Login} or not ${saml2Login}">
|
<div class="data-section-title" th:text="#{account.yourApiKey}">API Key</div>
|
||||||
<h4 th:text="#{account.changePassword}">Change Password?</h4>
|
<div class="data-panel data-mb-3">
|
||||||
<form id="formsavechangepassword" class="bg-card mt-4 mb-4" th:action="@{'/api/v1/user/change-password'}" method="post">
|
<div class="data-header">
|
||||||
<div class="mb-3">
|
<h5 class="data-title">
|
||||||
<label for="currentPassword" th:text="#{account.oldPassword}">Old Password</label>
|
<span class="data-icon">
|
||||||
<input type="password" class="form-control" name="currentPassword" id="currentPassword" th:placeholder="#{account.oldPassword}">
|
<span class="material-symbols-rounded">key</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{account.yourApiKey}">API Key</span>
|
||||||
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="data-body">
|
||||||
<label for="newPassword" th:text="#{account.newPassword}">New Password</label>
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<input type="password" class="form-control" name="newPassword" id="newPassword" th:placeholder="#{account.newPassword}">
|
<input type="password" class="data-form-control" id="apiKey" th:placeholder="#{account.yourApiKey}" readonly style="flex: 1;">
|
||||||
</div>
|
<button class="data-btn data-btn-secondary" id="copyBtn" type="button" onclick="copyToClipboard()" title="Copy to clipboard">
|
||||||
<div class="mb-3">
|
<span class="material-symbols-rounded">content_copy</span>
|
||||||
<label for="confirmNewPassword" th:text="#{account.confirmNewPassword}">Confirm New Password</label>
|
</button>
|
||||||
<input type="password" class="form-control" name="confirmNewPassword" id="confirmNewPassword" th:placeholder="#{account.confirmNewPassword}">
|
<button class="data-btn data-btn-secondary" id="showBtn" type="button" onclick="showApiKey()" title="Show/hide API key">
|
||||||
</div>
|
<span class="material-symbols-rounded" id="eyeIcon">visibility</span>
|
||||||
<div class="mb-3">
|
</button>
|
||||||
<span id="confirmPasswordError" style="display: none;" th:text="#{confirmPasswordErrorMessage}">New Password and Confirm New Password must match.</span>
|
<button class="data-btn data-btn-secondary" id="refreshBtn" type="button" onclick="refreshApiKey()" title="Refresh API key">
|
||||||
<button type="submit" class="btn btn-primary" th:text="#{account.changePassword}">Change Password</button>
|
<span class="material-symbols-rounded">refresh</span>
|
||||||
</div>
|
</button>
|
||||||
</form>
|
|
||||||
</th:block>
|
|
||||||
|
|
||||||
<!-- API Key Form -->
|
|
||||||
<h4 th:text="#{account.yourApiKey}">API Key</h4>
|
|
||||||
<div class="card mt-4 mb-4">
|
|
||||||
<div class="card-header" th:text="#{account.yourApiKey}"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input type="password" class="form-control" id="apiKey" th:placeholder="#{account.yourApiKey}" readonly>
|
|
||||||
<div class="input-group-append">
|
|
||||||
<button class="btn btn-secondary" id="copyBtn" type="button" onclick="copyToClipboard()">
|
|
||||||
<span class="material-symbols-rounded">
|
|
||||||
content_copy
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" id="showBtn" type="button" onclick="showApiKey()">
|
|
||||||
<span class="material-symbols-rounded" id="eyeIcon">
|
|
||||||
visibility
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" id="refreshBtn" type="button" onclick="refreshApiKey()">
|
|
||||||
<span class="material-symbols-rounded">
|
|
||||||
refresh
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script th:inline="javascript">
|
|
||||||
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
|
||||||
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
|
||||||
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
|
||||||
|
|
||||||
// Regular expression for email addresses: Max. 320 characters, with RFC-like validation
|
<!-- Settings Sync Section -->
|
||||||
const regexEmail = /^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.[A-Za-z]{2,})$/;
|
<div class="data-section-title" th:text="#{account.syncTitle}">Sync browser settings with Account</div>
|
||||||
|
<div class="data-panel data-mb-3">
|
||||||
// Check if the field is optional or meets the requirements
|
<div class="data-header">
|
||||||
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
<h5 class="data-title">
|
||||||
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
<span class="data-icon">
|
||||||
$(document).ready(function() {
|
<span class="material-symbols-rounded">sync</span>
|
||||||
$.validator.addMethod("passwordMatch", function(value, element) {
|
</span>
|
||||||
return $('#newPassword').val() === $('#confirmNewPassword').val();
|
<span th:text="#{account.settingsCompare}">Settings Comparison</span>
|
||||||
}, /*[[#{confirmPasswordErrorMessage}]]*/ "New Password and Confirm New Password must match.");
|
</h5>
|
||||||
$('#formsavechangepassword').validate({
|
</div>
|
||||||
rules: {
|
<div class="data-body">
|
||||||
currentPassword: {
|
<div class="table-responsive">
|
||||||
required: true
|
<table id="settingsTable" class="data-table">
|
||||||
},
|
<thead>
|
||||||
newPassword: {
|
<tr>
|
||||||
required: true
|
<th scope="col" th:text="#{account.property}">Property</th>
|
||||||
},
|
<th scope="col" th:text="#{account.accountSettings}">Account Setting</th>
|
||||||
confirmNewPassword: {
|
<th scope="col" th:text="#{account.webBrowserSettings}">Web Browser Setting</th>
|
||||||
required: true,
|
</tr>
|
||||||
passwordMatch: true
|
</thead>
|
||||||
},
|
<tbody>
|
||||||
errorPlacement: function(error, element) {
|
<!-- This will be dynamically populated by JavaScript -->
|
||||||
if ($(element).attr("name") === "newPassword" || $(element).attr("name") === "confirmNewPassword") {
|
</tbody>
|
||||||
$("#confirmPasswordError").text(error.text()).show();
|
</table>
|
||||||
} else {
|
</div>
|
||||||
error.insertAfter(element);
|
|
||||||
}
|
<div class="data-actions data-mt-3">
|
||||||
},
|
<button id="syncToBrowser" class="data-btn data-btn-primary">
|
||||||
success: function(label, element) {
|
<span class="material-symbols-rounded">cloud_download</span>
|
||||||
if ($(element).attr("name") === "newPassword" || $(element).attr("name") === "confirmNewPassword") {
|
<span th:text="#{account.syncToBrowser}">Sync Account -> Browser</span>
|
||||||
$("#confirmPasswordError").hide();
|
</button>
|
||||||
}
|
<button id="syncToAccount" class="data-btn data-btn-secondary">
|
||||||
}
|
<span class="material-symbols-rounded">cloud_upload</span>
|
||||||
}
|
<span th:text="#{account.syncToAccount}">Sync Account <- Browser</span>
|
||||||
});
|
</button>
|
||||||
|
</div>
|
||||||
$('#formsavechangeusername').validate({
|
|
||||||
rules: {
|
|
||||||
newUsername: {
|
|
||||||
required: true,
|
|
||||||
usernamePattern: true
|
|
||||||
},
|
|
||||||
currentPasswordChangeUsername: {
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
newUsername: {
|
|
||||||
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
errorPlacement: function(error, element) {
|
|
||||||
if (element.attr("name") === "newUsername") {
|
|
||||||
$("#usernameError").text(error.text()).show();
|
|
||||||
} else {
|
|
||||||
error.insertAfter(element);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: function(label, element) {
|
|
||||||
if ($(element).attr("name") === "newUsername") {
|
|
||||||
$("#usernameError").hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script th:inline="javascript">
|
|
||||||
function copyToClipboard() {
|
|
||||||
const apiKeyElement = document.getElementById("apiKey");
|
|
||||||
apiKeyElement.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
}
|
|
||||||
|
|
||||||
function showApiKey() {
|
|
||||||
const apiKeyElement = document.getElementById("apiKey");
|
|
||||||
const copyBtn = document.getElementById("copyBtn");
|
|
||||||
const eyeIcon = document.getElementById("eyeIcon");
|
|
||||||
if (apiKeyElement.type === "password") {
|
|
||||||
apiKeyElement.type = "text";
|
|
||||||
eyeIcon.textContent = "visibility_off";
|
|
||||||
copyBtn.disabled = false; // Enable copy button when API key is visible
|
|
||||||
} else {
|
|
||||||
apiKeyElement.type = "password";
|
|
||||||
eyeIcon.textContent = "visibility";
|
|
||||||
copyBtn.disabled = true; // Disable copy button when API key is hidden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async function() {
|
|
||||||
showApiKey();
|
|
||||||
try {
|
|
||||||
/*<![CDATA[*/
|
|
||||||
const urlGetApiKey = /*[[@{/api/v1/user/get-api-key}]]*/ "/api/v1/user/get-api-key";
|
|
||||||
/*]]>*/
|
|
||||||
let response = await window.fetchWithCsrf(urlGetApiKey, { method: 'POST' });
|
|
||||||
if (response.status === 200) {
|
|
||||||
let apiKey = await response.text();
|
|
||||||
manageUIState(apiKey);
|
|
||||||
} else {
|
|
||||||
manageUIState(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('There was an error:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function refreshApiKey() {
|
|
||||||
try {
|
|
||||||
/*<![CDATA[*/
|
|
||||||
const urlUpdateApiKey = /*[[@{/api/v1/user/update-api-key}]]*/ "/api/v1/user/update-api-key";
|
|
||||||
/*]]>*/
|
|
||||||
let response = await window.fetchWithCsrf(urlUpdateApiKey, { method: 'POST' });
|
|
||||||
if (response.status === 200) {
|
|
||||||
let apiKey = await response.text();
|
|
||||||
manageUIState(apiKey);
|
|
||||||
document.getElementById("apiKey").type = 'text';
|
|
||||||
document.getElementById("copyBtn").disabled = false;
|
|
||||||
} else {
|
|
||||||
alert('Error refreshing API key.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('There was an error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function manageUIState(apiKey) {
|
|
||||||
const apiKeyElement = document.getElementById("apiKey");
|
|
||||||
const showBtn = document.getElementById("showBtn");
|
|
||||||
const copyBtn = document.getElementById("copyBtn");
|
|
||||||
|
|
||||||
if (apiKey && apiKey.trim().length > 0) {
|
|
||||||
apiKeyElement.value = apiKey;
|
|
||||||
showBtn.disabled = false;
|
|
||||||
copyBtn.disabled = false;
|
|
||||||
} else {
|
|
||||||
apiKeyElement.value = "";
|
|
||||||
showBtn.disabled = true;
|
|
||||||
copyBtn.disabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h4 th:text="#{account.syncTitle}">Sync browser settings with Account</h4>
|
|
||||||
<div class="bg-card container mt-4">
|
|
||||||
<h3 th:text="#{account.settingsCompare}">Settings Comparison:</h3>
|
|
||||||
<table id="settingsTable" class="table table-bordered table-sm table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" th:text="#{account.property}">Property</th>
|
|
||||||
<th scope="col" th:text="#{account.accountSettings}">Account Setting</th>
|
|
||||||
<th scope="col" th:text="#{account.webBrowserSettings}">Web Browser Setting</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<!-- This will be dynamically populated by JavaScript -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="buttons-container mt-3 text-center">
|
|
||||||
<button id="syncToBrowser" class="btn btn-primary btn-sm" th:text="#{account.syncToBrowser}">Sync Account -> Browser</button>
|
|
||||||
<button id="syncToAccount" class="btn btn-secondary btn-sm" th:text="#{account.syncToAccount}">Sync Account <- Browser</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script th:inline="javascript">
|
|
||||||
document.addEventListener("DOMContentLoaded", async function() {
|
|
||||||
const settingsTableBody = document.querySelector("#settingsTable tbody");
|
|
||||||
|
|
||||||
/*<![CDATA[*/
|
|
||||||
var accountSettingsString = /*[[${settings}]]*/ {};
|
|
||||||
/*]]>*/
|
|
||||||
var accountSettings = JSON.parse(accountSettingsString);
|
|
||||||
|
|
||||||
let allKeys = new Set([...Object.keys(accountSettings), ...Object.keys(localStorage)]);
|
|
||||||
|
|
||||||
allKeys.forEach(key => {
|
|
||||||
if(key === 'debug' || key === '0' || key === '1' || key.includes('pdfjs') || key.includes('posthog') || key.includes('pageViews')) return; // Ignoring specific keys
|
|
||||||
|
|
||||||
const accountValue = accountSettings[key] || '-';
|
|
||||||
const browserValue = localStorage.getItem(key) || '-';
|
|
||||||
|
|
||||||
const row = settingsTableBody.insertRow();
|
|
||||||
const propertyCell = row.insertCell(0);
|
|
||||||
const accountCell = row.insertCell(1);
|
|
||||||
const browserCell = row.insertCell(2);
|
|
||||||
|
|
||||||
propertyCell.textContent = key;
|
|
||||||
accountCell.textContent = accountValue;
|
|
||||||
browserCell.textContent = browserValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('syncToBrowser').addEventListener('click', function() {
|
|
||||||
// First, clear the local storage
|
|
||||||
localStorage.clear();
|
|
||||||
|
|
||||||
// Then, set the account settings to local storage
|
|
||||||
for (let key in accountSettings) {
|
|
||||||
if(key !== 'debug' && key !== '0' && key !== '1' && !key.includes('pdfjs') && !key.includes('posthog') && !key.includes('pageViews')) { // Only sync non-ignored keys
|
|
||||||
localStorage.setItem(key, accountSettings[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
location.reload(); // Refresh the page after sync
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('syncToAccount').addEventListener('click', async function() {
|
|
||||||
/*<![CDATA[*/
|
|
||||||
const urlUpdateUserSettings = /*[[@{/api/v1/user/updateUserSettings}]]*/ "/api/v1/user/updateUserSettings";
|
|
||||||
/*]]>*/
|
|
||||||
|
|
||||||
let settings = {};
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if(key !== 'debug' && key !== '0' && key !== '1' && !key.includes('pdfjs') && !key.includes('posthog') && !key.includes('pageViews')) {
|
|
||||||
settings[key] = localStorage.getItem(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await window.fetchWithCsrf(urlUpdateUserSettings, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(settings)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
alert('Error syncing settings to account');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error syncing settings to account');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<div class="mb-3 mt-4 text-center">
|
|
||||||
<a th:href="@{'/logout'}" role="button" class="btn btn-danger" th:text="#{account.signOut}">Sign Out</a>
|
|
||||||
<a th:if="${role == 'ROLE_ADMIN'}" class="btn btn-info" th:href="@{'/adminSettings'}" role="button" th:text="#{account.adminSettings}" target="_blank">Admin Settings</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Username Modal -->
|
||||||
|
<div class="modal fade" id="changeUsernameModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<form id="formsavechangeusername" th:action="@{'/api/v1/user/change-username'}" 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">edit</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{account.changeUsername}">Change Username</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="newUsername" class="data-form-label" th:text="#{account.newUsername}">New Username</label>
|
||||||
|
<input type="text" class="data-form-control" name="newUsername" id="newUsername" th:placeholder="#{account.newUsername}">
|
||||||
|
<span id="usernameError" style="display: none; color: var(--md-sys-color-error);" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="currentPasswordChangeUsername" class="data-form-label" th:text="#{password}">Password</label>
|
||||||
|
<input type="password" class="data-form-control" name="currentPasswordChangeUsername" id="currentPasswordChangeUsername" th:placeholder="#{password}">
|
||||||
|
</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="#{account.changeUsername}">Change Username</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Modal -->
|
||||||
|
<div class="modal fade" id="changePasswordModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<form id="formsavechangepassword" th:action="@{'/api/v1/user/change-password'}" 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">key</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{account.changePassword}">Change Password</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="currentPassword" class="data-form-label" th:text="#{account.oldPassword}">Old Password</label>
|
||||||
|
<input type="password" class="data-form-control" name="currentPassword" id="currentPassword" th:placeholder="#{account.oldPassword}">
|
||||||
|
</div>
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="newPassword" class="data-form-label" th:text="#{account.newPassword}">New Password</label>
|
||||||
|
<input type="password" class="data-form-control" name="newPassword" id="newPassword" th:placeholder="#{account.newPassword}">
|
||||||
|
</div>
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="confirmNewPassword" class="data-form-label" th:text="#{account.confirmNewPassword}">Confirm New Password</label>
|
||||||
|
<input type="password" class="data-form-control" name="confirmNewPassword" id="confirmNewPassword" th:placeholder="#{account.confirmNewPassword}">
|
||||||
|
<span id="confirmPasswordError" style="display: none; color: var(--md-sys-color-error);" th:text="#{confirmPasswordErrorMessage}">New Password and Confirm New Password must match.</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" class="data-btn data-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">check</span>
|
||||||
|
<span th:text="#{account.changePassword}">Change Password</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript for validation -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
||||||
|
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
||||||
|
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
||||||
|
|
||||||
|
// Regular expression for email addresses: Max. 320 characters, with RFC-like validation
|
||||||
|
const regexEmail = /^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.[A-Za-z]{2,})$/;
|
||||||
|
|
||||||
|
// Check if the field is optional or meets the requirements
|
||||||
|
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
||||||
|
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
$.validator.addMethod("passwordMatch", function(value, element) {
|
||||||
|
return $('#newPassword').val() === $('#confirmNewPassword').val();
|
||||||
|
}, /*[[#{confirmPasswordErrorMessage}]]*/ "New Password and Confirm New Password must match.");
|
||||||
|
|
||||||
|
$('#formsavechangepassword').validate({
|
||||||
|
rules: {
|
||||||
|
currentPassword: {
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
newPassword: {
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
confirmNewPassword: {
|
||||||
|
required: true,
|
||||||
|
passwordMatch: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorPlacement: function(error, element) {
|
||||||
|
if (element.attr("name") === "newPassword" || element.attr("name") === "confirmNewPassword") {
|
||||||
|
$("#confirmPasswordError").text(error.text()).show();
|
||||||
|
} else {
|
||||||
|
error.insertAfter(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: function(label, element) {
|
||||||
|
if ($(element).attr("name") === "newPassword" || $(element).attr("name") === "confirmNewPassword") {
|
||||||
|
$("#confirmPasswordError").hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#formsavechangeusername').validate({
|
||||||
|
rules: {
|
||||||
|
newUsername: {
|
||||||
|
required: true,
|
||||||
|
usernamePattern: true
|
||||||
|
},
|
||||||
|
currentPasswordChangeUsername: {
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
newUsername: {
|
||||||
|
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errorPlacement: function(error, element) {
|
||||||
|
if (element.attr("name") === "newUsername") {
|
||||||
|
$("#usernameError").text(error.text()).show();
|
||||||
|
} else {
|
||||||
|
error.insertAfter(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: function(label, element) {
|
||||||
|
if ($(element).attr("name") === "newUsername") {
|
||||||
|
$("#usernameError").hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JavaScript for API Key -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
function copyToClipboard() {
|
||||||
|
const apiKeyElement = document.getElementById("apiKey");
|
||||||
|
apiKeyElement.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApiKey() {
|
||||||
|
const apiKeyElement = document.getElementById("apiKey");
|
||||||
|
const copyBtn = document.getElementById("copyBtn");
|
||||||
|
const eyeIcon = document.getElementById("eyeIcon");
|
||||||
|
if (apiKeyElement.type === "password") {
|
||||||
|
apiKeyElement.type = "text";
|
||||||
|
eyeIcon.textContent = "visibility_off";
|
||||||
|
copyBtn.disabled = false; // Enable copy button when API key is visible
|
||||||
|
} else {
|
||||||
|
apiKeyElement.type = "password";
|
||||||
|
eyeIcon.textContent = "visibility";
|
||||||
|
copyBtn.disabled = true; // Disable copy button when API key is hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async function() {
|
||||||
|
showApiKey();
|
||||||
|
try {
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const urlGetApiKey = /*[[@{/api/v1/user/get-api-key}]]*/ "/api/v1/user/get-api-key";
|
||||||
|
/*]]>*/
|
||||||
|
let response = await window.fetchWithCsrf(urlGetApiKey, { method: 'POST' });
|
||||||
|
if (response.status === 200) {
|
||||||
|
let apiKey = await response.text();
|
||||||
|
manageUIState(apiKey);
|
||||||
|
} else {
|
||||||
|
manageUIState(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshApiKey() {
|
||||||
|
try {
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const urlUpdateApiKey = /*[[@{/api/v1/user/update-api-key}]]*/ "/api/v1/user/update-api-key";
|
||||||
|
/*]]>*/
|
||||||
|
let response = await window.fetchWithCsrf(urlUpdateApiKey, { method: 'POST' });
|
||||||
|
if (response.status === 200) {
|
||||||
|
let apiKey = await response.text();
|
||||||
|
manageUIState(apiKey);
|
||||||
|
document.getElementById("apiKey").type = 'text';
|
||||||
|
document.getElementById("copyBtn").disabled = false;
|
||||||
|
} else {
|
||||||
|
alert('Error refreshing API key.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function manageUIState(apiKey) {
|
||||||
|
const apiKeyElement = document.getElementById("apiKey");
|
||||||
|
const showBtn = document.getElementById("showBtn");
|
||||||
|
const copyBtn = document.getElementById("copyBtn");
|
||||||
|
|
||||||
|
if (apiKey && apiKey.trim().length > 0) {
|
||||||
|
apiKeyElement.value = apiKey;
|
||||||
|
showBtn.disabled = false;
|
||||||
|
copyBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
apiKeyElement.value = "";
|
||||||
|
showBtn.disabled = true;
|
||||||
|
copyBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JavaScript for Settings Sync -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener("DOMContentLoaded", async function() {
|
||||||
|
const settingsTableBody = document.querySelector("#settingsTable tbody");
|
||||||
|
|
||||||
|
// Helper function to check if a key should be ignored
|
||||||
|
function shouldIgnoreKey(key) {
|
||||||
|
return key === 'debug' ||
|
||||||
|
key === '0' ||
|
||||||
|
key === '1' ||
|
||||||
|
key.includes('pdfjs') ||
|
||||||
|
key.includes('posthog') || key.includes('ssoRedirectAttempts') || key.includes('lastRedirectAttempt') || key.includes('surveyVersion') ||
|
||||||
|
key.includes('pageViews');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*<![CDATA[*/
|
||||||
|
var accountSettingsString = /*[[${settings}]]*/ {};
|
||||||
|
/*]]>*/
|
||||||
|
var accountSettings = JSON.parse(accountSettingsString);
|
||||||
|
|
||||||
|
let allKeys = new Set([...Object.keys(accountSettings), ...Object.keys(localStorage)]);
|
||||||
|
|
||||||
|
allKeys.forEach(key => {
|
||||||
|
if(shouldIgnoreKey(key)) return; // Using our helper function
|
||||||
|
|
||||||
|
const accountValue = accountSettings[key] || '-';
|
||||||
|
const browserValue = localStorage.getItem(key) || '-';
|
||||||
|
|
||||||
|
const row = settingsTableBody.insertRow();
|
||||||
|
const propertyCell = row.insertCell(0);
|
||||||
|
const accountCell = row.insertCell(1);
|
||||||
|
const browserCell = row.insertCell(2);
|
||||||
|
|
||||||
|
propertyCell.textContent = key;
|
||||||
|
accountCell.textContent = accountValue;
|
||||||
|
browserCell.textContent = browserValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('syncToBrowser').addEventListener('click', function() {
|
||||||
|
// First, clear the local storage
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
// Then, set the account settings to local storage
|
||||||
|
for (let key in accountSettings) {
|
||||||
|
if(!shouldIgnoreKey(key)) { // Using our helper function
|
||||||
|
localStorage.setItem(key, accountSettings[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
location.reload(); // Refresh the page after sync
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('syncToAccount').addEventListener('click', async function() {
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const urlUpdateUserSettings = /*[[@{/api/v1/user/updateUserSettings}]]*/ "/api/v1/user/updateUserSettings";
|
||||||
|
/*]]>*/
|
||||||
|
|
||||||
|
let settings = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if(!shouldIgnoreKey(key)) { // Using our helper function
|
||||||
|
settings[key] = localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.fetchWithCsrf(urlUpdateUserSettings, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(settings)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error syncing settings to account');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error syncing settings to account');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -1,18 +1,19 @@
|
|||||||
<!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>
|
||||||
<th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}"></th:block>
|
<th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}"></th:block>
|
||||||
|
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
|
||||||
<style>
|
<style>
|
||||||
.active-user {
|
.active-user {
|
||||||
color: green;
|
color: var(--md-sys-color-tertiary);
|
||||||
text-shadow: 0 0 5px green;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-overflow {
|
.text-overflow {
|
||||||
max-width: 100px;
|
max-width: 100px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow:ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -22,215 +23,354 @@
|
|||||||
<div id="page-container">
|
<div id="page-container">
|
||||||
<div id="content-wrap">
|
<div id="content-wrap">
|
||||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||||
<br><br>
|
|
||||||
<div class="container">
|
<div class="data-container">
|
||||||
<div class="row justify-content-center">
|
<div class="data-panel">
|
||||||
<div class="col-md-9 bg-card">
|
<div class="data-header">
|
||||||
<div class="tool-header">
|
<h1 class="data-title">
|
||||||
<span class="material-symbols-rounded tool-header-icon organize">manage_accounts</span>
|
<span class="data-icon">
|
||||||
<span class="tool-header-text" th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
|
<span class="material-symbols-rounded">manage_accounts</span>
|
||||||
</div>
|
</span>
|
||||||
|
<span th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
|
||||||
<!-- User Settings Title -->
|
</h1>
|
||||||
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
|
</div>
|
||||||
<a href="#"
|
|
||||||
th:data-bs-toggle="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : 'modal'"
|
<div class="data-body">
|
||||||
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
|
<!-- User Stats Banner -->
|
||||||
th:class="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
|
<div class="data-panel data-mb-3" style="background-color: var(--md-sys-color-primary-container);">
|
||||||
th:title="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
|
<div class="data-body" style="padding: 1.25rem;">
|
||||||
<span class="material-symbols-rounded">person_add</span>
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-around; align-items: center; gap: 1.5rem;">
|
||||||
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
</a>
|
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
|
||||||
|
group
|
||||||
<a href="#"
|
</span>
|
||||||
data-bs-toggle="modal"
|
<div>
|
||||||
data-bs-target="#changeUserRoleModal"
|
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.totalUsers}">Total Users</div>
|
||||||
class="btn btn-outline-success"
|
<div style="color: var(--md-sys-color-primary); font-size: 1.5rem; font-weight: 700;">
|
||||||
th:title="#{adminUserSettings.changeUserRole}">
|
<span th:text="${totalUsers}"></span>
|
||||||
<span class="material-symbols-rounded">edit</span>
|
<span th:if="${@runningProOrHigher}" th:text="'/' + ${maxPaidUsers}" style="font-size: 1rem;"></span>
|
||||||
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
|
</div>
|
||||||
<a href="/usage" th:if="${@runningEE}"
|
|
||||||
class="btn btn-outline-success"
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
th:title="#{adminUserSettings.usage}">
|
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
|
||||||
<span class="material-symbols-rounded">analytics</span>
|
check_circle
|
||||||
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
|
</span>
|
||||||
</a>
|
<div>
|
||||||
|
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.activeUsers}">Active Users</div>
|
||||||
<div class="my-4">
|
<div style="color: var(--md-sys-color-primary); font-size: 1.5rem; font-weight: 700;" th:text="${activeUsers}"></div>
|
||||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
|
</div>
|
||||||
<span th:text="${totalUsers}"></span>
|
</div>
|
||||||
<span th:if="${@runningProOrHigher}" th:text="'/'+${maxPaidUsers}"></span>
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
|
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
|
||||||
<span th:text="${activeUsers}"></span>
|
person_off
|
||||||
|
</span>
|
||||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
|
<div>
|
||||||
<span th:text="${disabledUsers}"></span>
|
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users</div>
|
||||||
</div>
|
<div style="color: var(--md-sys-color-primary); font-size: 1.5rem; font-weight: 700;" th:text="${disabledUsers}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div th:if="${addMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
|
|
||||||
<div class="alert alert-danger mb-auto">
|
|
||||||
<span th:text="#{${addMessage}}">Default message if not found</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div th:if="${changeMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
|
|
||||||
<div class="alert alert-danger mb-auto">
|
<!-- Alert Messages -->
|
||||||
<span th:text="#{${changeMessage}}">Default message if not found</span>
|
<div th:if="${addMessage}" class="alert alert-danger data-mb-3">
|
||||||
</div>
|
<span th:text="#{${addMessage}}">Default message if not found</span>
|
||||||
</div>
|
</div>
|
||||||
<div th:if="${deleteMessage}" class="alert alert-danger">
|
|
||||||
|
<div th:if="${changeMessage}" class="alert alert-danger 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>
|
<span th:text="#{${deleteMessage}}">Default message if not found</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-card mt-3 mb-3 table-responsive">
|
|
||||||
<table class="table table-striped table-hover">
|
<!-- Admin Actions -->
|
||||||
|
<div class="data-section-title">User Management</div>
|
||||||
|
<div class="data-actions data-mb-3">
|
||||||
|
<button
|
||||||
|
th:data-bs-toggle="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : 'modal'"
|
||||||
|
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
|
||||||
|
th:class="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? 'data-btn data-btn-danger' : 'data-btn data-btn-primary'"
|
||||||
|
th:title="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
|
||||||
|
<span class="material-symbols-rounded">person_add</span>
|
||||||
|
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="/teams" class="data-btn data-btn-secondary" th:title="#{adminUserSettings.teams}">
|
||||||
|
<span class="material-symbols-rounded">group</span>
|
||||||
|
<span th:text="#{adminUserSettings.teams}">Manage Teams</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#changeUserRoleModal"
|
||||||
|
class="data-btn data-btn-secondary"
|
||||||
|
th:title="#{adminUserSettings.changeUserRole}">
|
||||||
|
<span class="material-symbols-rounded">edit</span>
|
||||||
|
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="/usage" th:if="${@runningEE}" class="data-btn data-btn-secondary" th:title="#{adminUserSettings.usage}">
|
||||||
|
<span class="material-symbols-rounded">analytics</span>
|
||||||
|
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table">
|
||||||
<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}" th:text="#{username}">Username</th>
|
||||||
|
<th scope="col" th:title="#{adminUserSettings.team}" th:text="#{adminUserSettings.team}">Team</th>
|
||||||
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">Roles</th>
|
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">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}" colspan="2">Actions</th>
|
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}">Actions</th>
|
||||||
<!-- <th scope="col"></th> -->
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="user : ${users}">
|
<tr th:each="user : ${users}">
|
||||||
<th scope="row" style="align-content: center;" th:text="${user.id}"></th>
|
<td th:text="${user.id}"></td>
|
||||||
<td style="align-content: center;" th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
|
<td th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
|
||||||
<td style="align-content: center;" th:text="#{${user.roleName}}"></td>
|
<td th:text="${user.team != null ? user.team.name : '—'}"></td>
|
||||||
<td style="align-content: center;" th:text="${user.authenticationType}"></td>
|
<td>
|
||||||
<td style="align-content: center;" th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></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;">
|
||||||
<td style="align-content: center;">
|
<span class="material-symbols-rounded" style="font-size: 1rem;">shield</span>
|
||||||
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()">
|
<span th:text="#{${user.roleName}}">Role</span>
|
||||||
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="btn btn-info btn-sm"><span class="material-symbols-rounded">person_remove</span></button>
|
</span>
|
||||||
</form>
|
|
||||||
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}" th:href="@{'/account'}" class="btn btn-outline-success btn-sm"><span class="material-symbols-rounded">edit</span></a>
|
|
||||||
</td>
|
</td>
|
||||||
<td style="align-content: center;">
|
<td th:text="${user.authenticationType}"></td>
|
||||||
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post" onsubmit="return confirmChangeUserStatus()">
|
<td th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td>
|
||||||
<input type="hidden" name="enabled" th:value="!${user.enabled}" />
|
<td>
|
||||||
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}" class="btn btn-success btn-sm">
|
<div class="data-action-cell">
|
||||||
<span class="material-symbols-rounded">person</span>
|
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()" style="display: inline;">
|
||||||
</button>
|
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="data-icon-btn data-icon-btn-danger">
|
||||||
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}" class="btn btn-danger btn-sm">
|
<span class="material-symbols-rounded">person_remove</span>
|
||||||
<span class="material-symbols-rounded">person_off</span>
|
</button>
|
||||||
</button>
|
</form>
|
||||||
</form>
|
|
||||||
|
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}" th:href="@{'/account'}" class="data-icon-btn data-icon-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">edit</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post" onsubmit="return confirmChangeUserStatus()" style="display: inline;">
|
||||||
|
<input type="hidden" name="enabled" th:value="!${user.enabled}" />
|
||||||
|
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}" class="data-icon-btn data-icon-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">person</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}" class="data-icon-btn data-icon-btn-danger">
|
||||||
|
<span class="material-symbols-rounded">person_off</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p th:if="${!@runningProOrHigher}" th:text="#{enterpriseEdition.ssoAdvert}"></p>
|
|
||||||
|
<p th:if="${!@runningProOrHigher}" class="data-mt-3" th:text="#{enterpriseEdition.ssoAdvert}"></p>
|
||||||
<script th:inline="javascript">
|
|
||||||
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
|
|
||||||
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?';
|
|
||||||
function confirmDeleteUser() {
|
|
||||||
return confirm(delete_confirm_text);
|
|
||||||
}
|
|
||||||
function confirmChangeUserStatus() {
|
|
||||||
return confirm(change_confirm_text);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- change User role Modal start -->
|
<!-- Change User Role Modal -->
|
||||||
<div class="modal fade" id="changeUserRoleModal" tabindex="-1" aria-labelledby="changeUserRoleModalLabel" aria-hidden="true">
|
<div class="modal fade" id="changeUserRoleModal" tabindex="-1" aria-labelledby="changeUserRoleModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post" class="modal-content data-modal">
|
||||||
<div class="modal-header">
|
<div class="data-modal-header">
|
||||||
<h2 th:text="#{adminUserSettings.changeUserRole}">Change User's Role</h2>
|
<h5 class="data-modal-title">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
<span class="data-icon">
|
||||||
|
<span class="material-symbols-rounded">edit</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
<span class="material-symbols-rounded">close</span>
|
<span class="material-symbols-rounded">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="data-modal-body">
|
||||||
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{downgradeCurrentUserLongMessage}" th:text="#{help}">Help</button>
|
<div class="data-mb-2">
|
||||||
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post">
|
<button class="data-btn data-btn-secondary" data-toggle="tooltip" data-placement="auto" th:title="#{downgradeCurrentUserLongMessage}" style="padding: 0.25rem 0.5rem;">
|
||||||
<div class="mb-3">
|
<span class="material-symbols-rounded">help</span>
|
||||||
<label for="username" th:text="#{username}">Username</label>
|
<span th:text="#{help}">Help</span>
|
||||||
<select name="username" class="form-control" required>
|
</button>
|
||||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
</div>
|
||||||
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}" th:text="${user.username}">Username</option>
|
|
||||||
</select>
|
<div class="data-form-group">
|
||||||
</div>
|
<label for="username" class="data-form-label" th:text="#{username}">Username</label>
|
||||||
<div class="mb-3">
|
<select name="username" id="username" class="data-form-control" required>
|
||||||
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
|
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||||
<select name="role" class="form-control" required>
|
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}" th:text="${user.username}">Username</option>
|
||||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
</select>
|
||||||
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
<div class="data-form-group">
|
||||||
|
<label for="role" class="data-form-label" th:text="#{adminUserSettings.role}">Role</label>
|
||||||
<!-- Add other fields as required -->
|
<select name="role" id="role" class="data-form-control" required>
|
||||||
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
|
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||||
</form>
|
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="team" class="data-form-label" th:text="#{adminUserSettings.team}">Team</label>
|
||||||
|
<select name="teamId" id="team" class="data-form-control" required>
|
||||||
|
<option value="" th:text="#{selectFillter}">-- Select --</option>
|
||||||
|
<option th:each="team : ${teams}" th:value="${team.id}" th:text="${team.name}"></option>
|
||||||
|
</select>
|
||||||
|
</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.submit}">Save User</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer"></div>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- change User role Modal end -->
|
|
||||||
|
|
||||||
<!-- Add User Modal start -->
|
<!-- Add User Modal -->
|
||||||
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post" class="modal-content data-modal">
|
||||||
<div class="modal-header">
|
<div class="data-modal-header">
|
||||||
<h5 class="modal-title" id="addUserModalLabel" th:text="#{adminUserSettings.addUser}">Add New User</h5>
|
<h5 class="data-modal-title">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
<span class="data-icon">
|
||||||
|
<span class="material-symbols-rounded">person_add</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
<span class="material-symbols-rounded">close</span>
|
<span class="material-symbols-rounded">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="data-modal-body">
|
||||||
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{adminUserSettings.usernameInfo}" th:text="#{help}">Help</button>
|
<div class="data-mb-2">
|
||||||
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post">
|
<button class="data-btn data-btn-secondary" data-toggle="tooltip" data-placement="auto" th:title="#{adminUserSettings.usernameInfo}" style="padding: 0.25rem 0.5rem;">
|
||||||
<div class="mb-3">
|
<span class="material-symbols-rounded">help</span>
|
||||||
<label for="username" th:text="#{username}">Username</label>
|
<span th:text="#{help}">Help</span>
|
||||||
<input type="text" class="form-control" name="username" id="username" th:title="#{adminUserSettings.usernameInfo}" required>
|
</button>
|
||||||
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="mb-3" id="passwordContainer">
|
<div class="data-form-group">
|
||||||
<label for="password" th:text="#{password}">Password</label>
|
<label for="username" class="data-form-label" th:text="#{username}">Username</label>
|
||||||
<input type="password" class="form-control" name="password" id="password" required>
|
<input type="text" class="data-form-control" name="username" id="username" th:title="#{adminUserSettings.usernameInfo}" required>
|
||||||
</div>
|
<span id="usernameError" style="display: none; color: var(--md-sys-color-error);" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
||||||
<div class="mb-3">
|
</div>
|
||||||
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
|
|
||||||
<select name="role" class="form-control" id="role" required>
|
<div class="data-form-group" id="passwordContainer">
|
||||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
<label for="password" class="data-form-label" th:text="#{password}">Password</label>
|
||||||
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
<input type="password" class="data-form-control" name="password" id="password" required>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="data-form-group">
|
||||||
<label for="authType">Authentication Type</label>
|
<label for="role" class="data-form-label" th:text="#{adminUserSettings.role}">Role</label>
|
||||||
<select id="authType" name="authType" class="form-control" required>
|
<select name="role" class="data-form-control" id="role" required>
|
||||||
<option value="web" selected>WEB</option>
|
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||||
<option value="sso">SSO</option>
|
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check mb-3" id="checkboxContainer">
|
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="team" class="data-form-label" th:text="#{adminUserSettings.team}">Team</label>
|
||||||
|
<select name="teamId" class="data-form-control" required>
|
||||||
|
<option value="" th:text="#{selectFillter}">-- Select --</option>
|
||||||
|
<option th:each="team : ${teams}" th:value="${team.id}" th:text="${team.name}"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="authType" class="data-form-label">Authentication Type</label>
|
||||||
|
<select id="authType" name="authType" class="data-form-control" required>
|
||||||
|
<option value="web" selected>WEB</option>
|
||||||
|
<option value="sso">SSO</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-form-group" id="checkboxContainer">
|
||||||
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="forceChange" name="forceChange">
|
<input type="checkbox" class="form-check-input" id="forceChange" name="forceChange">
|
||||||
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user to change username/password on login</label>
|
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user to change username/password on login</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
|
</div>
|
||||||
</form>
|
|
||||||
|
<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.submit}">Save User</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer"></div>
|
</form>
|
||||||
</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.submit}">Create</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add User Modal end -->
|
|
||||||
|
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
|
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
|
||||||
|
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?';
|
||||||
|
|
||||||
|
function confirmDeleteUser() {
|
||||||
|
return confirm(delete_confirm_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmChangeUserStatus() {
|
||||||
|
return confirm(change_confirm_text);
|
||||||
|
}
|
||||||
|
|
||||||
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
||||||
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
||||||
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
||||||
@ -241,6 +381,7 @@
|
|||||||
// Check if the field is optional or meets the requirements
|
// Check if the field is optional or meets the requirements
|
||||||
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
||||||
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$('[data-toggle="tooltip"]').tooltip();
|
$('[data-toggle="tooltip"]').tooltip();
|
||||||
|
|
||||||
@ -261,9 +402,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
username: {
|
username: {
|
||||||
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
errorPlacement: function(error, element) {
|
errorPlacement: function(error, element) {
|
||||||
if (element.attr("name") === "username") {
|
if (element.attr("name") === "username") {
|
||||||
@ -280,36 +421,36 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#username').on('input', function() {
|
$('#username').on('input', function() {
|
||||||
var usernameInput = $(this);
|
var usernameInput = $(this);
|
||||||
var isValid = usernameInput[0].checkValidity();
|
var isValid = usernameInput[0].checkValidity();
|
||||||
var errorSpan = $('#usernameError');
|
var errorSpan = $('#usernameError');
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
usernameInput.removeClass('invalid').addClass('valid');
|
usernameInput.removeClass('invalid').addClass('valid');
|
||||||
errorSpan.hide();
|
errorSpan.hide();
|
||||||
} else {
|
} else {
|
||||||
usernameInput.removeClass('valid').addClass('invalid');
|
usernameInput.removeClass('valid').addClass('invalid');
|
||||||
errorSpan.show();
|
errorSpan.show();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#authType').on('change', function() {
|
$('#authType').on('change', function() {
|
||||||
var authType = $(this).val();
|
var authType = $(this).val();
|
||||||
var passwordField = $('#password');
|
var passwordField = $('#password');
|
||||||
var passwordFieldContainer = $('#passwordContainer');
|
var passwordFieldContainer = $('#passwordContainer');
|
||||||
var checkboxContainer = $('#checkboxContainer');
|
var checkboxContainer = $('#checkboxContainer');
|
||||||
|
|
||||||
if (authType === 'sso') {
|
if (authType === 'sso') {
|
||||||
passwordField.removeAttr('required');
|
passwordField.removeAttr('required');
|
||||||
passwordField.prop('disabled', true).val('');
|
passwordField.prop('disabled', true).val('');
|
||||||
passwordFieldContainer.slideUp('fast');
|
passwordFieldContainer.slideUp('fast');
|
||||||
checkboxContainer.slideUp('fast');
|
checkboxContainer.slideUp('fast');
|
||||||
} else {
|
} else {
|
||||||
passwordField.prop('disabled', false);
|
passwordField.prop('disabled', false);
|
||||||
passwordField.attr('required', 'required');
|
passwordField.attr('required', 'required');
|
||||||
passwordFieldContainer.slideDown('fast');
|
passwordFieldContainer.slideDown('fast');
|
||||||
checkboxContainer.slideDown('fast');
|
checkboxContainer.slideDown('fast');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
95
src/main/resources/templates/team-details.html
Normal file
95
src/main/resources/templates/team-details.html
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<!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="#{adminUserSettings.lastRequest}" 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="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}">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>
|
119
src/main/resources/templates/teams.html
Normal file
119
src/main/resources/templates/teams.html
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<!-- templates/teams.html -->
|
||||||
|
<!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">
|
||||||
|
<!-- Create New Team Button -->
|
||||||
|
<div class="data-actions">
|
||||||
|
<a href="#" data-bs-toggle="modal" data-bs-target="#addTeamModal"
|
||||||
|
class="data-btn data-btn-primary" th:title="#{adminUserSettings.createTeam}">
|
||||||
|
<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="#{adminUserSettings.lastRequest}" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
||||||
|
<th scope="col" th:text="#{adminUserSettings.actions}">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="team : ${teams}">
|
||||||
|
<td th:text="${team.name}"></td>
|
||||||
|
<td th:text="${team.users.size()}"></td>
|
||||||
|
<td th:text="${teamLastRequest[team.id] != null ? #dates.format(teamLastRequest[team.id], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td>
|
||||||
|
<td>
|
||||||
|
<div class="data-action-cell">
|
||||||
|
<a th:href="@{'/teams/' + ${team.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="${team.id}" />
|
||||||
|
<button type="submit" class="data-btn data-btn-danger data-btn-sm" th:title="#{adminUserSettings.deleteTeam}">
|
||||||
|
<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.submit}">Create</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,9 +1,73 @@
|
|||||||
<!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>
|
||||||
<th:block th:insert="~{fragments/common :: head(title=#{endpointStatistics.title}, header=#{endpointStatistics.header})}"></th:block>
|
<th:block th:insert="~{fragments/common :: head(title=#{endpointStatistics.title}, header=#{endpointStatistics.header})}"></th:block>
|
||||||
<script th:src="@{'/js/thirdParty/chart.umd.min.js'}"></script>
|
<script th:src="@{'/js/thirdParty/chart.umd.min.js'}"></script>
|
||||||
<link rel="stylesheet" th:href="@{'/css/usage.css'}">
|
<link rel="stylesheet" th:href="@{'/css/modern-tables.css'}">
|
||||||
|
<style>
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
height: 400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-filter-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stats-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stat-icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: var(--md-sys-color-primary-container);
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stat-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stat-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@ -11,65 +75,118 @@
|
|||||||
<div id="page-container">
|
<div id="page-container">
|
||||||
<div id="content-wrap">
|
<div id="content-wrap">
|
||||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||||
<br><br>
|
|
||||||
<div class="container">
|
<div class="data-container">
|
||||||
<div class="row justify-content-center">
|
<div class="data-panel">
|
||||||
<div class="col-md-9 bg-card">
|
<div class="data-header">
|
||||||
<div class="tool-header">
|
<h1 class="data-title">
|
||||||
<span class="material-symbols-rounded tool-header-icon">analytics</span>
|
<span class="data-icon">
|
||||||
<span class="tool-header-text" th:text="#{endpointStatistics.header}">Endpoint Statistics</span>
|
<span class="material-symbols-rounded">analytics</span>
|
||||||
</div>
|
</span>
|
||||||
|
<span th:text="#{endpointStatistics.header}">Endpoint Statistics</span>
|
||||||
<!-- Statistics Summary Box -->
|
</h1>
|
||||||
<div class="stats-box">
|
</div>
|
||||||
<div class="chart-controls">
|
|
||||||
<button id="top10Btn" class="btn btn-outline-primary active">
|
<div class="data-body">
|
||||||
<span class="material-symbols-rounded">bar_chart</span>
|
<!-- Data Control Panel -->
|
||||||
<span th:text="#{endpointStatistics.top10}">Top 10</span>
|
<div class="data-panel data-mb-3" style="background-color: var(--md-sys-color-surface-variant);">
|
||||||
</button>
|
<div class="data-body">
|
||||||
<button id="top20Btn" class="btn btn-outline-primary">
|
<!-- Chart Controls -->
|
||||||
<span class="material-symbols-rounded">data_usage</span>
|
<div class="data-section-title">Data Range</div>
|
||||||
<span th:text="#{endpointStatistics.top20}">Top 20</span>
|
<div class="data-actions">
|
||||||
</button>
|
<button id="top10Btn" class="data-btn data-btn-primary">
|
||||||
<button id="allBtn" class="btn btn-outline-primary">
|
<span class="material-symbols-rounded">bar_chart</span>
|
||||||
<span class="material-symbols-rounded">insights</span>
|
<span th:text="#{endpointStatistics.top10}">Top 10</span>
|
||||||
<span th:text="#{endpointStatistics.all}">All</span>
|
</button>
|
||||||
</button>
|
<button id="top20Btn" class="data-btn data-btn-secondary">
|
||||||
<button id="refreshBtn" class="btn btn-outline-secondary">
|
<span class="material-symbols-rounded">data_usage</span>
|
||||||
<span class="material-symbols-rounded">refresh</span>
|
<span th:text="#{endpointStatistics.top20}">Top 20</span>
|
||||||
<span th:text="#{endpointStatistics.refresh}">Refresh</span>
|
</button>
|
||||||
</button>
|
<button id="allBtn" class="data-btn data-btn-secondary">
|
||||||
</div>
|
<span class="material-symbols-rounded">insights</span>
|
||||||
|
<span th:text="#{endpointStatistics.all}">All</span>
|
||||||
<div class="filter-controls">
|
</button>
|
||||||
<label class="filter-checkbox">
|
<button id="refreshBtn" class="data-btn data-btn-secondary">
|
||||||
<input type="checkbox" id="hideHomeCheckbox" checked>
|
<span class="material-symbols-rounded">refresh</span>
|
||||||
<span th:text="#{endpointStatistics.includeHomepage}">Include Homepage ('/')</span>
|
<span th:text="#{endpointStatistics.refresh}">Refresh</span>
|
||||||
</label>
|
</button>
|
||||||
<label class="filter-checkbox">
|
</div>
|
||||||
<input type="checkbox" id="hideLoginCheckbox" checked>
|
|
||||||
<span th:text="#{endpointStatistics.includeLoginPage}">Include Login Page ('/login')</span>
|
<!-- Filters -->
|
||||||
</label>
|
<div class="data-section-title data-mt-3">Filters</div>
|
||||||
</div>
|
<div class="data-filter-group">
|
||||||
|
<label class="data-filter-checkbox">
|
||||||
<div class="my-4" style="color: var(--md-sys-color-on-surface); font-weight: 500;">
|
<input type="checkbox" id="hideHomeCheckbox" checked>
|
||||||
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.totalEndpoints} + ':'">Total Endpoints:</strong> <span id="totalEndpoints">0</span></span>
|
<span th:text="#{endpointStatistics.includeHomepage}">Include Homepage ('/')</span>
|
||||||
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.totalVisits} + ':'">Total Visits:</strong> <span id="totalVisits">0</span></span>
|
</label>
|
||||||
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.showing} + ':'">Showing:</strong> <span id="currentlyShowing">Top 10</span></span>
|
<label class="data-filter-checkbox">
|
||||||
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.selectedVisits} + ':'">Selected Visits:</strong> <span id="displayedVisits">0</span> (<span id="displayedPercentage">0</span>%)</span>
|
<input type="checkbox" id="hideLoginCheckbox" checked>
|
||||||
|
<span th:text="#{endpointStatistics.includeLoginPage}">Include Login Page ('/login')</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Summary -->
|
||||||
|
<div class="data-stats-summary">
|
||||||
|
<div class="data-stat-item">
|
||||||
|
<div class="data-stat-icon">
|
||||||
|
<span class="material-symbols-rounded">link</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-stat-content">
|
||||||
|
<div class="data-stat-label" th:text="#{endpointStatistics.totalEndpoints}">Total Endpoints</div>
|
||||||
|
<div class="data-stat-value" id="totalEndpoints">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-stat-item">
|
||||||
|
<div class="data-stat-icon">
|
||||||
|
<span class="material-symbols-rounded">visibility</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-stat-content">
|
||||||
|
<div class="data-stat-label" th:text="#{endpointStatistics.totalVisits}">Total Visits</div>
|
||||||
|
<div class="data-stat-value" id="totalVisits">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-stat-item">
|
||||||
|
<div class="data-stat-icon">
|
||||||
|
<span class="material-symbols-rounded">filter_list</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-stat-content">
|
||||||
|
<div class="data-stat-label" th:text="#{endpointStatistics.showing}">Showing</div>
|
||||||
|
<div class="data-stat-value" id="currentlyShowing">Top 10</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-stat-item">
|
||||||
|
<div class="data-stat-icon">
|
||||||
|
<span class="material-symbols-rounded">percent</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-stat-content">
|
||||||
|
<div class="data-stat-label" th:text="#{endpointStatistics.selectedVisits}">Selected Visits</div>
|
||||||
|
<div class="data-stat-value">
|
||||||
|
<span id="displayedVisits">0</span>
|
||||||
|
(<span id="displayedPercentage">0</span>%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chart Container -->
|
<!-- Chart Container -->
|
||||||
<div class="bg-card mt-3 mb-3">
|
<div class="data-section-title">Visualization</div>
|
||||||
<div class="chart-container">
|
<div class="data-panel data-mb-3">
|
||||||
<canvas id="endpointChart"></canvas>
|
<div class="data-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="endpointChart"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table for detailed data -->
|
<!-- Table for detailed data -->
|
||||||
<div class="bg-card mt-3 mb-3">
|
<div class="data-section-title">Detailed Data</div>
|
||||||
<table class="endpoint-table">
|
<div class="table-responsive">
|
||||||
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" style="width: 5%;">#</th>
|
<th scope="col" style="width: 5%;">#</th>
|
||||||
@ -83,26 +200,64 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state (will be shown/hidden by JavaScript) -->
|
||||||
|
<div id="emptyState" class="data-empty" style="display: none;">
|
||||||
|
<span class="material-symbols-rounded data-empty-icon">analytics_off</span>
|
||||||
|
<p class="data-empty-text">No endpoint data available</p>
|
||||||
|
<button id="retryBtn" class="data-btn data-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">refresh</span>
|
||||||
|
<span th:text="#{endpointStatistics.retry}">Retry</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" th:src="@{'/js/usage.js'}"></script>
|
<script type="module" th:src="@{'/js/usage.js'}"></script>
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
const endpointStatsTranslations = {
|
const endpointStatsTranslations = {
|
||||||
all: /*[[#{endpointStatistics.all}]]*/ 'All',
|
all: /*[[#{endpointStatistics.all}]]*/ 'All',
|
||||||
top20: /*[[#{endpointStatistics.top20}]]*/ 'Top 20',
|
top20: /*[[#{endpointStatistics.top20}]]*/ 'Top 20',
|
||||||
loading: /*[[#{endpointStatistics.loading}]]*/ 'Loading...',
|
loading: /*[[#{endpointStatistics.loading}]]*/ 'Loading...',
|
||||||
failedToLoad: /*[[#{endpointStatistics.failedToLoad}]]*/ 'Failed to load endpoint data. Please try refreshing.',
|
failedToLoad: /*[[#{endpointStatistics.failedToLoad}]]*/ 'Failed to load endpoint data. Please try refreshing.',
|
||||||
home: /*[[#{endpointStatistics.home}]]*/ 'Home',
|
home: /*[[#{endpointStatistics.home}]]*/ 'Home',
|
||||||
login: /*[[#{endpointStatistics.login}]]*/ 'Login',
|
login: /*[[#{endpointStatistics.login}]]*/ 'Login',
|
||||||
top: /*[[#{endpointStatistics.top}]]*/ 'Top ',
|
top: /*[[#{endpointStatistics.top}]]*/ 'Top ',
|
||||||
numberOfVisits: /*[[#{endpointStatistics.numberOfVisits}]]*/ 'Number of Visits',
|
numberOfVisits: /*[[#{endpointStatistics.numberOfVisits}]]*/ 'Number of Visits',
|
||||||
visitsTooltip: /*[[#{endpointStatistics.visitsTooltip}]]*/ 'Visits: {0} ({1}% of total)',
|
visitsTooltip: /*[[#{endpointStatistics.visitsTooltip}]]*/ 'Visits: {0} ({1}% of total)',
|
||||||
retry: /*[[#{endpointStatistics.retry}]]*/ 'Retry'
|
retry: /*[[#{endpointStatistics.retry}]]*/ 'Retry'
|
||||||
};
|
};
|
||||||
</script>
|
|
||||||
|
// Add active class handling for view buttons
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const buttons = [
|
||||||
|
document.getElementById('top10Btn'),
|
||||||
|
document.getElementById('top20Btn'),
|
||||||
|
document.getElementById('allBtn')
|
||||||
|
];
|
||||||
|
|
||||||
|
buttons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
btn.classList.remove('data-btn-primary');
|
||||||
|
btn.classList.add('data-btn-secondary');
|
||||||
|
});
|
||||||
|
this.classList.remove('data-btn-secondary');
|
||||||
|
this.classList.add('data-btn-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect retry button to refresh
|
||||||
|
const retryBtn = document.getElementById('retryBtn');
|
||||||
|
if (retryBtn) {
|
||||||
|
retryBtn.addEventListener('click', function() {
|
||||||
|
document.getElementById('refreshBtn').click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user