team stuff

This commit is contained in:
Anthony Stirling 2025-06-09 00:32:45 +01:00
parent 0a9bfb44ce
commit 84a2bc7ef8
31 changed files with 2690 additions and 594 deletions

View File

@ -255,7 +255,7 @@ public class AppConfig {
@Bean(name = "disablePixel")
public boolean disablePixel() {
return Boolean.getBoolean(env.getProperty("DISABLE_PIXEL"));
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
}
@Bean(name = "machineType")

View File

@ -0,0 +1,44 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
import jakarta.persistence.*;
import lombok.*;
import stirling.software.proprietary.security.model.User;
@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);
}
}

View File

@ -1,6 +1,7 @@
package stirling.software.proprietary.security;
import java.sql.SQLException;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Component;
@ -13,7 +14,10 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@ -22,9 +26,8 @@ import stirling.software.proprietary.security.service.UserService;
public class InitialSecuritySetup {
private final UserService userService;
private final TeamService teamService;
private final ApplicationProperties applicationProperties;
private final DatabaseServiceInterface databaseService;
@PostConstruct
@ -40,6 +43,7 @@ public class InitialSecuritySetup {
}
userService.migrateOauth2ToSSO();
assignUsersToDefaultTeamIfMissing();
initializeInternalApiUser();
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.error("Failed to initialize security setup.", e);
@ -47,6 +51,19 @@ public class InitialSecuritySetup {
}
}
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 {
String initialUsername =
applicationProperties.getSecurity().getInitialLogin().getUsername();
@ -58,7 +75,9 @@ public class InitialSecuritySetup {
&& !initialPassword.isEmpty()
&& userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) {
userService.saveUser(initialUsername, initialPassword, Role.ADMIN.getRoleId());
Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser(
initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false);
log.info("Admin user created: {}", initialUsername);
} else {
createDefaultAdminUser();
@ -70,7 +89,9 @@ public class InitialSecuritySetup {
String defaultPassword = "stirling";
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);
}
}
@ -78,10 +99,13 @@ public class InitialSecuritySetup {
private void initializeInternalApiUser()
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
Team team = teamService.getOrCreateInternalTeam();
userService.saveUser(
Role.INTERNAL_API_USER.getRoleId(),
UUID.randomUUID().toString(),
Role.INTERNAL_API_USER.getRoleId());
team,
Role.INTERNAL_API_USER.getRoleId(),
false);
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
}

View File

@ -1,4 +1,4 @@
package stirling.software.proprietary.security.controller.web;
package stirling.software.proprietary.security.config;
import static stirling.software.common.util.ProviderUtils.validateProvider;
@ -38,10 +38,12 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@ -57,16 +59,19 @@ public class AccountWebController {
// Assuming you have a repository for user operations
private final UserRepository userRepository;
private final boolean runningEE;
private final TeamRepository teamRepository;
public AccountWebController(
ApplicationProperties applicationProperties,
SessionPersistentRegistry sessionPersistentRegistry,
UserRepository userRepository,
TeamRepository teamRepository,
@Qualifier("runningEE") boolean runningEE) {
this.applicationProperties = applicationProperties;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.userRepository = userRepository;
this.runningEE = runningEE;
this.teamRepository=teamRepository;
}
@GetMapping("/login")
@ -210,7 +215,7 @@ public class AccountWebController {
@GetMapping("/adminSettings")
public String showAddUserForm(
HttpServletRequest request, Model model, Authentication authentication) {
List<User> allUsers = userRepository.findAll();
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
// Map to store session information and user activity status
@ -331,6 +336,9 @@ public class AccountWebController {
model.addAttribute("activeUsers", activeUsers);
model.addAttribute("disabledUsers", disabledUsers);
List<Team> allTeams = teamRepository.findAll();
model.addAttribute("teams", allTeams);
model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers());
return "adminSettings";
}

View File

@ -0,0 +1,13 @@
package stirling.software.proprietary.security.config;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation to mark endpoints that require a Pro or higher license.
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PremiumEndpoint {}

View File

@ -0,0 +1,29 @@
package stirling.software.proprietary.security.config;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Aspect
@Component
public class PremiumEndpointAspect {
private final boolean runningProOrHigher;
public PremiumEndpointAspect(@Qualifier("runningProOrHigher") boolean runningProOrHigher) {
this.runningProOrHigher = runningProOrHigher;
}
@Around("@annotation(stirling.software.proprietary.security.config.PremiumEndpoint) || @within(stirling.software.proprietary.security.config.PremiumEndpoint)")
public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable {
if (!runningProOrHigher) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This endpoint requires a Pro or higher license");
}
return joinPoint.proceed();
}
}

View File

@ -21,8 +21,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
@Slf4j
@Getter
@Configuration
@EnableJpaRepositories(basePackages = "stirling.software.proprietary.security.database.repository")
@EntityScan({"stirling.software.proprietary.security.model"})
@EnableJpaRepositories(basePackages = {"stirling.software.proprietary.security.database.repository", "stirling.software.proprietary.security.repository"})
@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"})
public class DatabaseConfig {
public final String DATASOURCE_DEFAULT_URL;

View File

@ -0,0 +1,80 @@
package stirling.software.proprietary.security.controller.api;
import java.util.Optional;
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.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
@Controller
@RequestMapping("/api/v1/team")
@Tag(name = "Team", description = "Team Management APIs")
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
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");
}
}

View File

@ -32,10 +32,13 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@ -50,6 +53,7 @@ public class UserController {
private final UserService userService;
private final SessionPersistentRegistry sessionRegistry;
private final ApplicationProperties applicationProperties;
private final TeamRepository teamRepository;
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register")
@ -60,7 +64,13 @@ public class UserController {
return "register";
}
try {
userService.saveUser(requestModel.getUsername(), requestModel.getPassword());
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
userService.saveUser(
requestModel.getUsername(),
requestModel.getPassword(),
team,
Role.USER.getRoleId(),
false);
} catch (IllegalArgumentException e) {
return "redirect:/login?messageType=invalidUsername";
}
@ -233,13 +243,14 @@ public class UserController {
// If the role ID is not valid, redirect with an error message
return new RedirectView("/adminSettings?messageType=invalidRole", true);
}
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
userService.saveUser(username, AuthenticationType.SSO, role);
userService.saveUser(username, AuthenticationType.SSO, team, role);
} else {
if (password.isBlank()) {
return new RedirectView("/adminSettings?messageType=invalidPassword", true);
}
userService.saveUser(username, password, role, forceChange);
userService.saveUser(username, password, team, role, forceChange);
}
return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user

View File

@ -0,0 +1,89 @@
package stirling.software.proprietary.security.controller.web;
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.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
@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 "enterprise/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 "enterprise/team-details";
}
}

View File

@ -29,4 +29,20 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
@Param("expired") boolean expired,
@Param("lastRequest") Date lastRequest,
@Param("principalName") String principalName);
@Query(
"SELECT t.id as teamId, MAX(s.lastRequest) as lastActivity "
+ "FROM stirling.software.proprietary.model.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 stirling.software.proprietary.security.model.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);
}

View File

@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
@Repository
@ -22,4 +23,14 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByApiKey(String apiKey);
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);
}

View File

@ -16,6 +16,7 @@ import lombok.Setter;
import lombok.ToString;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@Entity
@Table(name = "users")
@ -57,6 +58,10 @@ public class User implements Serializable {
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
private Set<Authority> authorities = new HashSet<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
@ElementCollection
@MapKeyColumn(name = "setting_key")
@Lob

View File

@ -0,0 +1,20 @@
package stirling.software.proprietary.security.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.proprietary.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);
}

View File

@ -0,0 +1,40 @@
package stirling.software.proprietary.security.service;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.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);
});
}
}

View File

@ -8,6 +8,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
@ -31,11 +32,13 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@ -45,7 +48,7 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
public class UserService implements UserServiceInterface {
private final UserRepository userRepository;
private final TeamRepository teamRepository;
private final AuthorityRepository authorityRepository;
private final PasswordEncoder passwordEncoder;
@ -162,7 +165,7 @@ public class UserService implements UserServiceInterface {
public void saveUser(String username, AuthenticationType authenticationType)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUser(username, authenticationType, Role.USER.getRoleId());
saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
}
private User saveUser(Optional<User> user, String apiKey) {
@ -173,71 +176,98 @@ public class UserService implements UserServiceInterface {
throw new UsernameNotFoundException("User not found");
}
public void saveUser(String username, AuthenticationType authenticationType, String role)
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));
user.setAuthenticationType(authenticationType);
userRepository.save(user);
databaseService.exportDatabase();
return saveUserCore(
username, // username
null, // password
authenticationType, // authenticationType
teamId, // teamId
null, // team
role, // role
false, // firstLogin
true // enabled
);
}
public void saveUser(String username, String password)
public User saveUser(
String username, AuthenticationType authenticationType, Team team, String role)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!isUsernameValid(username)) {
throw new IllegalArgumentException(getInvalidUsernameMessage());
}
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setEnabled(true);
user.setAuthenticationType(AuthenticationType.WEB);
user.addAuthority(new Authority(Role.USER.getRoleId(), user));
userRepository.save(user);
databaseService.exportDatabase();
return saveUserCore(
username, // username
null, // password
authenticationType, // authenticationType
null, // teamId
team, // team
role, // role
false, // firstLogin
true // enabled
);
}
public void saveUser(String username, String password, String role, boolean firstLogin)
public User saveUser(String username, String password, Long teamId)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!isUsernameValid(username)) {
throw new IllegalArgumentException(getInvalidUsernameMessage());
}
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.addAuthority(new Authority(role, user));
user.setEnabled(true);
user.setAuthenticationType(AuthenticationType.WEB);
user.setFirstLogin(firstLogin);
userRepository.save(user);
databaseService.exportDatabase();
return saveUserCore(
username, // username
password, // password
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
Role.USER.getRoleId(), // role
false, // firstLogin
true // enabled
);
}
public void saveUser(String username, String password, String role)
public User saveUser(
String username, String password, Team team, String role, boolean firstLogin)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUser(username, password, role, false);
return saveUserCore(
username, // username
password, // password
AuthenticationType.WEB, // authenticationType
null, // teamId
team, // team
role, // role
firstLogin, // firstLogin
true // enabled
);
}
public void saveUser(String username, String password, boolean firstLogin, boolean enabled)
public User saveUser(
String username, String password, Long teamId, String role, boolean firstLogin)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!isUsernameValid(username)) {
throw new IllegalArgumentException(getInvalidUsernameMessage());
}
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.addAuthority(new Authority(Role.USER.getRoleId(), user));
user.setEnabled(enabled);
user.setAuthenticationType(AuthenticationType.WEB);
user.setFirstLogin(firstLogin);
userRepository.save(user);
databaseService.exportDatabase();
return saveUserCore(
username, // username
password, // password
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
role, // role
firstLogin, // firstLogin
true // enabled
);
}
public void saveUser(String username, String password, Long teamId, String role)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUser(username, password, teamId, role, false);
}
public void saveUser(
String username, String password, Long teamId, boolean firstLogin, boolean enabled)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUserCore(
username, // username
password, // password
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
Role.USER.getRoleId(), // role
firstLogin, // firstLogin
enabled // enabled
);
}
public void deleteUser(String username) {
@ -345,6 +375,111 @@ public class UserService implements UserServiceInterface {
return passwordEncoder.matches(currentPassword, user.getPassword());
}
/**
* Resolves a team based on the provided information, with consistent error handling.
*
* @param teamId The ID of the team to find, may be null
* @param defaultTeamSupplier A supplier that provides a default team when teamId is null
* @return The resolved Team object
* @throws IllegalArgumentException If the teamId is invalid
*/
private Team resolveTeam(Long teamId, Supplier<Team> defaultTeamSupplier) {
if (teamId == null) {
return defaultTeamSupplier.get();
}
return teamRepository
.findById(teamId)
.orElseThrow(() -> new IllegalArgumentException("Invalid team ID: " + teamId));
}
/**
* Gets the default team, creating it if it doesn't exist.
*
* @return The default team
*/
private Team getDefaultTeam() {
return teamRepository
.findByName("Default")
.orElseGet(
() -> {
Team team = new Team();
team.setName("Default");
return teamRepository.save(team);
});
}
/**
* Core implementation for saving a user with all possible parameters. This method centralizes
* the common logic for all saveUser variants.
*
* @param username Username for the new user
* @param password Password for the user (may be null for SSO/OAuth users)
* @param authenticationType Type of authentication (WEB, SSO, etc.)
* @param teamId ID of the team to assign (may be null to use default)
* @param team Team object to assign (takes precedence over teamId if both provided)
* @param role Role to assign to the user
* @param firstLogin Whether this is the user's first login
* @param enabled Whether the user account is enabled
* @return The saved User object
* @throws IllegalArgumentException If username is invalid or team is invalid
* @throws SQLException If database operation fails
* @throws UnsupportedProviderException If provider is not supported
*/
private User saveUserCore(
String username,
String password,
AuthenticationType authenticationType,
Long teamId,
Team team,
String role,
boolean firstLogin,
boolean enabled)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!isUsernameValid(username)) {
throw new IllegalArgumentException(getInvalidUsernameMessage());
}
User user = new User();
user.setUsername(username);
// Set password if provided
if (password != null && !password.isEmpty()) {
user.setPassword(passwordEncoder.encode(password));
}
// Set authentication type
user.setAuthenticationType(authenticationType);
// Set enabled status
user.setEnabled(enabled);
// Set first login flag
user.setFirstLogin(firstLogin);
// Set role (authority)
if (role == null) {
role = Role.USER.getRoleId();
}
user.addAuthority(new Authority(role, user));
// Resolve and set team
if (team != null) {
user.setTeam(team);
} else {
user.setTeam(resolveTeam(teamId, this::getDefaultTeam));
}
// Save user
userRepository.save(user);
// Export database
databaseService.exportDatabase();
return user;
}
public boolean isUsernameValid(String username) {
// Checks whether the simple username is formatted correctly
// Regular expression for user name: Min. 3 characters, max. 50 characters
@ -464,7 +599,6 @@ public class UserService implements UserServiceInterface {
}
}
@Override
public long getTotalUsersCount() {
// Count all users in the database
long userCount = userRepository.count();
@ -474,4 +608,12 @@ public class UserService implements UserServiceInterface {
}
return userCount;
}
public List<User> getUsersWithoutTeam() {
return userRepository.findAllWithoutTeam();
}
public void saveAll(List<User> users) {
userRepository.saveAll(users);
}
}

View 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
}

View 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>

View File

@ -0,0 +1,122 @@
<!-- templates/enterprise/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="#"
th:data-bs-toggle="${@runningProOrHigher} ? 'modal' : null"
th:data-bs-target="${@runningProOrHigher} ? '#addTeamModal' : null"
th:class="${@runningProOrHigher} ? 'data-btn data-btn-primary' : 'data-btn data-btn-danger'"
th:title="${@runningProOrHigher} ? #{adminUserSettings.createTeam} : #{enterpriseEdition.proTeamFeatureDisabled}">
<span class="material-symbols-rounded">group_add</span>
<span th:text="#{adminUserSettings.createTeam}">Create New Team</span>
</a>
</div>
<!-- Team Table -->
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th scope="col" th:text="#{adminUserSettings.teamName}">Team Name</th>
<th scope="col" th:text="#{adminUserSettings.totalMembers}">Total Members</th>
<th scope="col" th:title="#{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>

View File

@ -2,6 +2,6 @@ plugins {
// Apply the foojay-resolver plugin to allow automatic download of JDKs
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = 'Stirling-PDF'
rootProject.name = 'Stirling PDF'
include 'stirling-pdf', 'common', 'proprietary'

View File

@ -0,0 +1,262 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class EditTableOfContentsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
@PostMapping(value = "/extract-bookmarks", consumes = "multipart/form-data")
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ResponseBody
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception {
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
if (outline == null) {
log.info("No outline/bookmarks found in PDF");
return new ArrayList<>();
}
return extractBookmarkItems(document, outline);
} finally {
if (document != null) {
document.close();
}
}
}
private List<Map<String, Object>> extractBookmarkItems(PDDocument document, PDDocumentOutline outline) throws Exception {
List<Map<String, Object>> bookmarks = new ArrayList<>();
PDOutlineItem current = outline.getFirstChild();
while (current != null) {
Map<String, Object> bookmark = new HashMap<>();
// Get bookmark title
String title = current.getTitle();
bookmark.put("title", title);
// Get page number (1-based for UI purposes)
PDPage page = current.findDestinationPage(document);
if (page != null) {
int pageIndex = document.getPages().indexOf(page);
bookmark.put("pageNumber", pageIndex + 1);
} else {
bookmark.put("pageNumber", 1);
}
// Process children if any
PDOutlineItem child = current.getFirstChild();
if (child != null) {
List<Map<String, Object>> children = new ArrayList<>();
PDOutlineNode parent = current;
while (child != null) {
// Recursively process child items
Map<String, Object> childBookmark = processChild(document, child);
children.add(childBookmark);
child = child.getNextSibling();
}
bookmark.put("children", children);
} else {
bookmark.put("children", new ArrayList<>());
}
bookmarks.add(bookmark);
current = current.getNextSibling();
}
return bookmarks;
}
private Map<String, Object> processChild(PDDocument document, PDOutlineItem item) throws Exception {
Map<String, Object> bookmark = new HashMap<>();
// Get bookmark title
String title = item.getTitle();
bookmark.put("title", title);
// Get page number (1-based for UI purposes)
PDPage page = item.findDestinationPage(document);
if (page != null) {
int pageIndex = document.getPages().indexOf(page);
bookmark.put("pageNumber", pageIndex + 1);
} else {
bookmark.put("pageNumber", 1);
}
// Process children if any
PDOutlineItem child = item.getFirstChild();
if (child != null) {
List<Map<String, Object>> children = new ArrayList<>();
while (child != null) {
// Recursively process child items
Map<String, Object> childBookmark = processChild(document, child);
children.add(childBookmark);
child = child.getNextSibling();
}
bookmark.put("children", children);
} else {
bookmark.put("children", new ArrayList<>());
}
return bookmark;
}
@PostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data")
@Operation(
summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.")
public ResponseEntity<byte[]> editTableOfContents(@ModelAttribute EditTableOfContentsRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
// Parse the bookmark data from JSON
List<BookmarkItem> bookmarks = objectMapper.readValue(
request.getBookmarkData(),
new TypeReference<List<BookmarkItem>>() {});
// Create a new document outline
PDDocumentOutline outline = new PDDocumentOutline();
document.getDocumentCatalog().setDocumentOutline(outline);
// Add bookmarks to the outline
addBookmarksToOutline(document, outline, bookmarks);
// Save the document to a byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
String filename = file.getOriginalFilename().replaceFirst("[.][^.]+$", "");
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(), filename + "_with_toc.pdf", MediaType.APPLICATION_PDF);
} finally {
if (document != null) {
document.close();
}
}
}
private void addBookmarksToOutline(PDDocument document, PDDocumentOutline outline, List<BookmarkItem> bookmarks) {
for (BookmarkItem bookmark : bookmarks) {
PDOutlineItem item = createOutlineItem(document, bookmark);
outline.addLast(item);
if (bookmark.getChildren() != null && !bookmark.getChildren().isEmpty()) {
addChildBookmarks(document, item, bookmark.getChildren());
}
}
}
private void addChildBookmarks(PDDocument document, PDOutlineItem parent, List<BookmarkItem> children) {
for (BookmarkItem child : children) {
PDOutlineItem item = createOutlineItem(document, child);
parent.addLast(item);
if (child.getChildren() != null && !child.getChildren().isEmpty()) {
addChildBookmarks(document, item, child.getChildren());
}
}
}
private PDOutlineItem createOutlineItem(PDDocument document, BookmarkItem bookmark) {
PDOutlineItem item = new PDOutlineItem();
item.setTitle(bookmark.getTitle());
// Get the target page - adjust for 0-indexed pages in PDFBox
int pageIndex = bookmark.getPageNumber() - 1;
if (pageIndex < 0) {
pageIndex = 0;
} else if (pageIndex >= document.getNumberOfPages()) {
pageIndex = document.getNumberOfPages() - 1;
}
PDPage page = document.getPage(pageIndex);
item.setDestination(page);
return item;
}
// Inner class to represent bookmarks in JSON
public static class BookmarkItem {
private String title;
private int pageNumber;
private List<BookmarkItem> children = new ArrayList<>();
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public int getPageNumber() {
return pageNumber;
}
public void setPageNumber(int pageNumber) {
this.pageNumber = pageNumber;
}
public List<BookmarkItem> getChildren() {
return children;
}
public void setChildren(List<BookmarkItem> children) {
this.children = children;
}
}
}

View File

@ -15,6 +15,8 @@ import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
@ -110,6 +112,46 @@ public class MergeController {
}
}
// Adds a table of contents to the merged document using filenames as chapter titles
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
// Create the document outline
PDDocumentOutline outline = new PDDocumentOutline();
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
int pageIndex = 0; // Current page index in the merged document
// Iterate through the original files
for (MultipartFile file : files) {
// Get the filename without extension to use as bookmark title
String filename = file.getOriginalFilename();
String title = filename;
if (title != null && title.contains(".")) {
title = title.substring(0, title.lastIndexOf('.'));
}
// Create an outline item for this file
PDOutlineItem item = new PDOutlineItem();
item.setTitle(title);
// Set the destination to the first page of this file in the merged document
if (pageIndex < mergedDocument.getNumberOfPages()) {
PDPage page = mergedDocument.getPage(pageIndex);
item.setDestination(page);
}
// Add the item to the outline
outline.addLast(item);
// Increment page index for the next file
try (PDDocument doc = pdfDocumentFactory.load(file)) {
pageIndex += doc.getNumberOfPages();
} catch (IOException e) {
log.error("Error loading document for TOC generation", e);
pageIndex++; // Increment by at least one if we can't determine page count
}
}
}
@PostMapping(consumes = "multipart/form-data", value = "/merge-pdfs")
@Operation(
summary = "Merge multiple PDF files into one",
@ -124,6 +166,7 @@ public class MergeController {
PDDocument mergedDocument = null;
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
boolean generateToc = request.isGenerateToc();
try {
MultipartFile[] files = request.getFileInput();
@ -169,6 +212,11 @@ public class MergeController {
}
}
}
// Add table of contents if generateToc is true
if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files);
}
// Save the modified document to a new ByteArrayOutputStream
ByteArrayOutputStream baos = new ByteArrayOutputStream();

View File

@ -0,0 +1,18 @@
package stirling.software.SPDF.model.api;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = false)
public class EditTableOfContentsRequest extends PDFFile {
@Schema(description = "Bookmark structure in JSON format", example = "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2}]}]")
private String bookmarkData;
@Schema(description = "Whether to replace existing bookmarks or append to them", example = "true")
private Boolean replaceExisting;
}

View File

@ -32,4 +32,11 @@ public class MergePdfsRequest extends MultiplePDFFiles {
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "true")
private Boolean removeCertSign;
@Schema(
description =
"Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean generateToc = false;
}

View File

@ -127,6 +127,7 @@ enterpriseEdition.button=Upgrade to Pro
enterpriseEdition.warning=This feature is only available to Pro users.
enterpriseEdition.yamlAdvert=Stirling PDF Pro supports YAML configuration files and other SSO features.
enterpriseEdition.ssoAdvert=Looking for more user management features? Check out Stirling PDF Pro
enterpriseEdition.proTeamFeatureDisabled=Team management features require a Pro licence or higher
#################
@ -239,6 +240,24 @@ adminUserSettings.totalUsers=Total Users:
adminUserSettings.lastRequest=Last Request
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.header=Endpoint Statistics
endpointStatistics.top10=Top 10
@ -1010,6 +1029,7 @@ merge.header=Merge multiple PDFs (2+)
merge.sortByName=Sort by name
merge.sortByDate=Sort by date
merge.removeCertSign=Remove digital signature in the merged file?
merge.generateToc=Generate table of contents in the merged file?
merge.submit=Merge
@ -1459,3 +1479,19 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
cookieBanner.preferencesModal.analytics.title=Analytics
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
# Table of Contents Feature
home.editTableOfContents.title=Edit Table of Contents
home.editTableOfContents.desc=Add or edit bookmarks and table of contents in PDF documents
editTableOfContents.tags=bookmarks,toc,navigation,index,table of contents,chapters,sections,outline
editTableOfContents.title=Edit Table of Contents
editTableOfContents.header=Add or Edit PDF Table of Contents
editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to append to existing)
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
editTableOfContents.submit=Apply Table of Contents

View File

@ -127,6 +127,7 @@ enterpriseEdition.button=Upgrade to Pro
enterpriseEdition.warning=This feature is only available to Pro users.
enterpriseEdition.yamlAdvert=Stirling PDF Pro supports YAML configuration files and other SSO features.
enterpriseEdition.ssoAdvert=Looking for more user management features? Check out Stirling PDF Pro
enterpriseEdition.proTeamFeatureDisabled=Team management features require a Pro license or higher
#################
@ -1010,6 +1011,7 @@ merge.header=Merge multiple PDFs (2+)
merge.sortByName=Sort by name
merge.sortByDate=Sort by date
merge.removeCertSign=Remove digital signature in the merged file?
merge.generateToc=Generate table of contents in the merged file?
merge.submit=Merge

View File

@ -0,0 +1,276 @@
/* Main bookmark container styles */
.bookmark-editor {
margin-top: 20px;
padding: 20px;
border: 1px solid var(--border-color, #ced4da);
border-radius: 0.25rem;
background-color: var(--bg-container, var(--md-sys-color-surface, #edf0f5));
}
[data-bs-theme="dark"] .bookmark-editor {
--border-color: #495057;
--bg-container: var(--md-sys-color-surface, #15202a);
}
/* Bookmark item styles */
.bookmark-item {
margin-bottom: 12px;
border: 1px solid var(--border-item, #e9ecef);
border-radius: 0.5rem;
background-color: var(--bg-item, var(--md-sys-color-surface-container-lowest, #ffffff));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
[data-bs-theme="dark"] .bookmark-item {
--border-item: var(--md-sys-color-surface-variant, #444b53);
--bg-item: var(--md-sys-color-surface-container-low, #24282e);
}
/* Bookmark header (collapsible part) */
.bookmark-header {
padding: 12px 15px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
background-color: var(--bg-header, var(--md-sys-color-surface-container, #f1f3f5));
border-bottom: 1px solid var(--border-header, var(--md-sys-color-outline-variant, #e9ecef));
transition: background-color 0.15s ease;
}
.bookmark-header:hover {
background-color: var(--bg-header-hover, var(--md-sys-state-hover-opacity, #e9ecef));
}
[data-bs-theme="dark"] .bookmark-header {
--bg-header: var(--md-sys-color-surface-container-high, #3a424a);
--bg-header-hover: var(--md-sys-color-surface-container-highest, #434a52);
--border-header: var(--md-sys-color-outline-variant, #444b53);
}
/* Bookmark content (inside accordion) */
.bookmark-content {
padding: 15px;
border-top: 0;
}
/* Children container */
.bookmark-children {
margin-top: 10px;
margin-left: 20px;
padding-left: 15px;
border-left: 2px solid var(--border-children, #6c757d);
}
[data-bs-theme="dark"] .bookmark-children {
--border-children: #6c757d;
}
/* Level indicators */
.bookmark-level-indicator {
font-size: 0.8em;
color: var(--text-muted, #6c757d);
font-weight: 500;
text-transform: uppercase;
margin-right: 8px;
}
[data-bs-theme="dark"] .bookmark-level-indicator {
--text-muted: #adb5bd;
}
/* Button styles */
.btn-bookmark-action {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
border-radius: 4px;
padding: 0;
margin-right: 6px;
transition: all 0.2s ease;
position: relative;
}
.btn-bookmark-action:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Visual distinction for child vs sibling buttons using theme colors */
.btn-add-child {
background-color: var(--btn-add-child-bg, var(--md-sys-color-surface-container-low, #e9ecef));
color: var(--btn-add-child-color, var(--md-nav-section-color-other, #72bd54));
border-color: var(--btn-add-child-border, var(--md-nav-section-color-other, #72bd54));
}
.btn-add-child::after {
content: "";
position: absolute;
left: -2px;
top: 50%;
width: 5px;
height: 1px;
background-color: var(--btn-add-child-border, var(--md-nav-section-color-other, #72bd54));
}
[data-bs-theme="dark"] .btn-add-child {
--btn-add-child-bg: var(--md-sys-color-surface-container, #28323a);
--btn-add-child-color: var(--favourite-add, #9ed18c);
--btn-add-child-border: var(--favourite-add, #9ed18c);
}
.btn-add-sibling {
background-color: var(--btn-add-sibling-bg, var(--md-sys-color-surface-container-low, #e9ecef));
color: var(--btn-add-sibling-color, var(--md-sys-color-primary, #0060aa));
border-color: var(--btn-add-sibling-border, var(--md-sys-color-primary, #0060aa));
}
[data-bs-theme="dark"] .btn-add-sibling {
--btn-add-sibling-bg: var(--md-sys-color-surface-container, #28323a);
--btn-add-sibling-color: var(--md-sys-color-primary, #a2c9ff);
--btn-add-sibling-border: var(--md-sys-color-primary, #a2c9ff);
}
.bookmark-actions-header {
display: flex;
margin-left: 8px;
}
.bookmark-actions-content {
display: flex;
justify-content: space-between;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border-subtle-color, #dee2e6);
}
[data-bs-theme="dark"] .bookmark-actions-content {
--border-subtle-color: #495057;
}
/* Main actions section */
.bookmark-actions {
margin-top: 20px;
display: flex;
justify-content: flex-start;
}
/* Collapse/expand icons */
.toggle-icon {
transition: transform 0.3s ease;
}
.collapsed .toggle-icon {
transform: rotate(-90deg);
}
/* Title and page display in header */
.bookmark-title-preview {
font-weight: 500;
margin-right: 10px;
color: var(--text-primary, var(--md-sys-color-on-surface, #212529));
}
[data-bs-theme="dark"] .bookmark-title-preview {
--text-primary: var(--md-sys-color-on-surface, #e9ecef);
}
.bookmark-page-preview {
font-size: 0.9em;
color: var(--text-secondary, var(--md-sys-color-on-surface-variant, #6c757d));
}
[data-bs-theme="dark"] .bookmark-page-preview {
--text-secondary: var(--md-sys-color-on-surface-variant, #adb5bd);
}
/* Input text colors */
.bookmark-content input,
.bookmark-content label {
color: var(--input-text, var(--md-sys-color-on-surface, #212529));
}
[data-bs-theme="dark"] .bookmark-content input,
[data-bs-theme="dark"] .bookmark-content label {
--input-text: var(--md-sys-color-on-surface, #e9ecef);
background-color: var(--input-bg, var(--md-sys-color-surface-container-high, #3a424a));
border-color: var(--input-border, var(--md-sys-color-outline, #495057));
}
/* We've removed the drag handle since it's not functional */
/* Add button at the top level */
.btn-add-bookmark {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
position: relative;
padding-left: 24px;
}
.btn-add-bookmark::before {
content: "";
position: absolute;
left: 12px;
top: 0;
width: 2px;
height: 50%;
background-color: currentColor;
}
.btn-add-bookmark.top-level::before {
display: none;
}
/* Relationship indicators */
.bookmark-relationship-indicator {
position: absolute;
left: -15px;
top: 0;
height: 100%;
width: 14px;
pointer-events: none;
}
.relationship-line {
position: absolute;
left: 7px;
top: 0;
height: 100%;
width: 2px;
background-color: var(--relationship-color, var(--md-nav-section-color-other, #72bd54));
}
.relationship-arrow {
position: absolute;
left: 5px;
top: 50%;
width: 8px;
height: 2px;
background-color: var(--relationship-color, var(--md-nav-section-color-other, #72bd54));
}
[data-bs-theme="dark"] .bookmark-relationship-indicator {
--relationship-color: var(--favourite-add, #9ed18c);
}
/* Empty state */
.empty-bookmarks {
padding: 30px;
text-align: center;
color: var(--text-muted, var(--md-sys-color-on-surface-variant, #6c757d));
background-color: var(--bg-empty, var(--md-sys-color-surface-container-lowest, #ffffff));
border-radius: 0.375rem;
border: 1px dashed var(--border-empty, var(--md-sys-color-outline, #ced4da));
}
[data-bs-theme="dark"] .empty-bookmarks {
--text-muted: var(--md-sys-color-on-surface-variant, #adb5bd);
--bg-empty: var(--md-sys-color-surface-container-low, #24282e);
--border-empty: var(--md-sys-color-outline, #495057);
}

View File

@ -1,7 +1,8 @@
<!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=#{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>
<body>
@ -9,353 +10,462 @@
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-9 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">settings_account_box</span>
<span class="tool-header-text" th:text="#{account.accountSettings}">User Settings</span>
</div>
<!-- User Settings Title -->
<th:block th:if="${messageType}">
<div class="alert alert-danger">
<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">settings_account_box</span>
</span>
<span th:text="#{account.accountSettings}">User Settings</span>
</h1>
</div>
<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>
</div>
</th:block>
<!-- 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">
<div th:if="${error}" class="alert alert-danger data-mb-3" role="alert">
<span th:text="${error}">Error Message</span>
</div>
</th:block>
<!-- Change Username Form -->
<!-- Admin Settings Banner (for admins only) -->
<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}">
<h4 th:text="#{account.changeUsername}">Change Username?</h4>
<form id="formsavechangeusername" class="bg-card mt-4 mb-4" th:action="@{'/api/v1/user/change-username'}" method="post">
<div class="mb-3">
<label for="newUsername" th:text="#{account.newUsername}">Change Username</label>
<input type="text" class="form-control" name="newUsername" id="newUsername" th:placeholder="#{account.newUsername}">
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid username!</span>
<div class="data-section-title">Account Management</div>
<div class="data-actions data-actions-start data-mb-3">
<button class="data-btn data-btn-primary" data-bs-toggle="modal" data-bs-target="#changeUsernameModal">
<span class="material-symbols-rounded">edit</span>
<span th:text="#{account.changeUsername}">Change 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 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>
<!-- Change Password Form -->
<th:block th:if="not ${oAuth2Login} or not ${saml2Login}">
<h4 th:text="#{account.changePassword}">Change Password?</h4>
<form id="formsavechangepassword" class="bg-card mt-4 mb-4" th:action="@{'/api/v1/user/change-password'}" method="post">
<div class="mb-3">
<label for="currentPassword" th:text="#{account.oldPassword}">Old Password</label>
<input type="password" class="form-control" name="currentPassword" id="currentPassword" th:placeholder="#{account.oldPassword}">
<!-- API Key Section -->
<div class="data-section-title" th:text="#{account.yourApiKey}">API Key</div>
<div class="data-panel data-mb-3">
<div class="data-header">
<h5 class="data-title">
<span class="data-icon">
<span class="material-symbols-rounded">key</span>
</span>
<span th:text="#{account.yourApiKey}">API Key</span>
</h5>
</div>
<div class="mb-3">
<label for="newPassword" th:text="#{account.newPassword}">New Password</label>
<input type="password" class="form-control" name="newPassword" id="newPassword" th:placeholder="#{account.newPassword}">
</div>
<div class="mb-3">
<label for="confirmNewPassword" th:text="#{account.confirmNewPassword}">Confirm New Password</label>
<input type="password" class="form-control" name="confirmNewPassword" id="confirmNewPassword" th:placeholder="#{account.confirmNewPassword}">
</div>
<div class="mb-3">
<span id="confirmPasswordError" style="display: none;" th:text="#{confirmPasswordErrorMessage}">New Password and Confirm New Password must match.</span>
<button type="submit" class="btn btn-primary" th:text="#{account.changePassword}">Change Password</button>
</div>
</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 class="data-body">
<div style="display: flex; gap: 0.5rem;">
<input type="password" class="data-form-control" id="apiKey" th:placeholder="#{account.yourApiKey}" readonly style="flex: 1;">
<button class="data-btn data-btn-secondary" id="copyBtn" type="button" onclick="copyToClipboard()" title="Copy to clipboard">
<span class="material-symbols-rounded">content_copy</span>
</button>
<button class="data-btn data-btn-secondary" id="showBtn" type="button" onclick="showApiKey()" title="Show/hide API key">
<span class="material-symbols-rounded" id="eyeIcon">visibility</span>
</button>
<button class="data-btn data-btn-secondary" id="refreshBtn" type="button" onclick="refreshApiKey()" title="Refresh API key">
<span class="material-symbols-rounded">refresh</span>
</button>
</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
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>
<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>
<!-- Settings Sync Section -->
<div class="data-section-title" th:text="#{account.syncTitle}">Sync browser settings with Account</div>
<div class="data-panel data-mb-3">
<div class="data-header">
<h5 class="data-title">
<span class="data-icon">
<span class="material-symbols-rounded">sync</span>
</span>
<span th:text="#{account.settingsCompare}">Settings Comparison</span>
</h5>
</div>
<div class="data-body">
<div class="table-responsive">
<table id="settingsTable" class="data-table">
<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>
<div class="data-actions data-mt-3">
<button id="syncToBrowser" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">cloud_download</span>
<span th:text="#{account.syncToBrowser}">Sync Account -> Browser</span>
</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>
</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>
<!-- 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>
</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('clientSubmissionOrder') ||
key.includes('lastSubmitTime') ||
key.includes('lastClientId') ||
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>
</html>

View File

@ -1,18 +1,19 @@
<!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.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>
.active-user {
color: green;
text-shadow: 0 0 5px green;
color: var(--md-sys-color-tertiary);
font-weight: 600;
}
.text-overflow {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow:ellipsis;
text-overflow: ellipsis;
}
</style>
</head>
@ -22,215 +23,354 @@
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-9 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">manage_accounts</span>
<span class="tool-header-text" th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
</div>
<!-- User Settings Title -->
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
<a href="#"
th:data-bs-toggle="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : 'modal'"
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
th:class="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
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>
</a>
<a href="#"
data-bs-toggle="modal"
data-bs-target="#changeUserRoleModal"
class="btn btn-outline-success"
th:title="#{adminUserSettings.changeUserRole}">
<span class="material-symbols-rounded">edit</span>
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
</a>
<a th:href="@{'/usage'}" th:if="${@runningEE}"
class="btn btn-outline-success"
th:title="#{adminUserSettings.usage}">
<span class="material-symbols-rounded">analytics</span>
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
</a>
<div class="my-4">
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
<span th:text="${totalUsers}"></span>
<span th:if="${@runningProOrHigher}" th:text="'/'+${maxPaidUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
<span th:text="${activeUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
<span th:text="${disabledUsers}"></span>
</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 class="data-container">
<div class="data-panel">
<div class="data-header">
<h1 class="data-title">
<span class="data-icon">
<span class="material-symbols-rounded">manage_accounts</span>
</span>
<span th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
</h1>
</div>
<div class="data-body">
<!-- User Stats Banner -->
<div class="data-panel data-mb-3" style="background-color: var(--md-sys-color-primary-container);">
<div class="data-body" style="padding: 1.25rem;">
<div style="display: flex; flex-wrap: wrap; justify-content: space-around; align-items: center; gap: 1.5rem;">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
group
</span>
<div>
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.totalUsers}">Total Users</div>
<div style="color: var(--md-sys-color-primary); font-size: 1.5rem; font-weight: 700;">
<span th:text="${totalUsers}"></span>
<span th:if="${@runningProOrHigher}" th:text="'/' + ${maxPaidUsers}" style="font-size: 1rem;"></span>
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 0.75rem;">
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
check_circle
</span>
<div>
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.activeUsers}">Active Users</div>
<div style="color: var(--md-sys-color-primary); font-size: 1.5rem; font-weight: 700;" th:text="${activeUsers}"></div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 0.75rem;">
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
person_off
</span>
<div>
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users</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>
<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">
<span th:text="#{${changeMessage}}">Default message if not found</span>
</div>
<!-- Alert Messages -->
<div th:if="${addMessage}" class="alert alert-danger data-mb-3">
<span th:text="#{${addMessage}}">Default message if not found</span>
</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>
</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>
<tr>
<th scope="col">#</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.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.actions}" th:text="#{adminUserSettings.actions}" colspan="2">Actions</th>
<!-- <th scope="col"></th> -->
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${users}">
<th scope="row" style="align-content: center;" th:text="${user.id}"></th>
<td style="align-content: center;" th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
<td style="align-content: center;" th:text="#{${user.roleName}}"></td>
<td style="align-content: center;" th:text="${user.authenticationType}"></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>
<td style="align-content: center;">
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()">
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="btn btn-info btn-sm"><span class="material-symbols-rounded">person_remove</span></button>
</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 th:text="${user.id}"></td>
<td th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
<td th:text="${user.team != null ? user.team.name : '—'}"></td>
<td>
<span class="data-badge" style="background-color: var(--md-sys-color-secondary-container); color: var(--md-sys-color-secondary); padding: 0.25rem 0.5rem; border-radius: 1rem; font-size: 0.875rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<span class="material-symbols-rounded" style="font-size: 1rem;">shield</span>
<span th:text="#{${user.roleName}}">Role</span>
</span>
</td>
<td style="align-content: center;">
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post" onsubmit="return confirmChangeUserStatus()">
<input type="hidden" name="enabled" th:value="!${user.enabled}" />
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}" class="btn btn-success btn-sm">
<span class="material-symbols-rounded">person</span>
</button>
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}" class="btn btn-danger btn-sm">
<span class="material-symbols-rounded">person_off</span>
</button>
</form>
<td th:text="${user.authenticationType}"></td>
<td th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td>
<td>
<div class="data-action-cell">
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()" style="display: inline;">
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="data-icon-btn data-icon-btn-danger">
<span class="material-symbols-rounded">person_remove</span>
</button>
</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>
</tr>
</tbody>
</table>
</div>
<p th:if="${!@runningProOrHigher}" 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>
<p th:if="${!@runningProOrHigher}" class="data-mt-3" th:text="#{enterpriseEdition.ssoAdvert}"></p>
</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-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h2 th:text="#{adminUserSettings.changeUserRole}">Change User's Role</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<div class="modal-dialog modal-dialog-centered">
<form th:action="@{'/api/v1/user/admin/changeRole'}" 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="#{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>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{downgradeCurrentUserLongMessage}" th:text="#{help}">Help</button>
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post">
<div class="mb-3">
<label for="username" th:text="#{username}">Username</label>
<select name="username" class="form-control" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}" th:text="${user.username}">Username</option>
</select>
</div>
<div class="mb-3">
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
<select name="role" class="form-control" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
</select>
</div>
<!-- Add other fields as required -->
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
</form>
<div class="data-modal-body">
<div class="data-mb-2">
<button class="data-btn data-btn-secondary" data-toggle="tooltip" data-placement="auto" th:title="#{downgradeCurrentUserLongMessage}" style="padding: 0.25rem 0.5rem;">
<span class="material-symbols-rounded">help</span>
<span th:text="#{help}">Help</span>
</button>
</div>
<div class="data-form-group">
<label for="username" class="data-form-label" th:text="#{username}">Username</label>
<select name="username" id="username" class="data-form-control" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}" th:text="${user.username}">Username</option>
</select>
</div>
<div class="data-form-group">
<label for="role" class="data-form-label" th:text="#{adminUserSettings.role}">Role</label>
<select name="role" id="role" class="data-form-control" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<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 class="modal-footer"></div>
</div>
</form>
</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-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addUserModalLabel" th:text="#{adminUserSettings.addUser}">Add New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<div class="modal-dialog modal-dialog-centered">
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post" class="modal-content data-modal">
<div class="data-modal-header">
<h5 class="data-modal-title">
<span class="data-icon">
<span class="material-symbols-rounded">person_add</span>
</span>
<span th:text="#{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>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{adminUserSettings.usernameInfo}" th:text="#{help}">Help</button>
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post">
<div class="mb-3">
<label for="username" th:text="#{username}">Username</label>
<input type="text" class="form-control" name="username" id="username" th:title="#{adminUserSettings.usernameInfo}" required>
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid username!</span>
</div>
<div class="mb-3" id="passwordContainer">
<label for="password" th:text="#{password}">Password</label>
<input type="password" class="form-control" name="password" id="password" required>
</div>
<div class="mb-3">
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
<select name="role" class="form-control" id="role" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
</select>
</div>
<div class="mb-3">
<label for="authType">Authentication Type</label>
<select id="authType" name="authType" class="form-control" required>
<option value="web" selected>WEB</option>
<option value="sso">SSO</option>
</select>
</div>
<div class="form-check mb-3" id="checkboxContainer">
<div class="data-modal-body">
<div class="data-mb-2">
<button class="data-btn data-btn-secondary" data-toggle="tooltip" data-placement="auto" th:title="#{adminUserSettings.usernameInfo}" style="padding: 0.25rem 0.5rem;">
<span class="material-symbols-rounded">help</span>
<span th:text="#{help}">Help</span>
</button>
</div>
<div class="data-form-group">
<label for="username" class="data-form-label" th:text="#{username}">Username</label>
<input type="text" class="data-form-control" name="username" id="username" th:title="#{adminUserSettings.usernameInfo}" required>
<span id="usernameError" style="display: none; color: var(--md-sys-color-error);" th:text="#{invalidUsernameMessage}">Invalid username!</span>
</div>
<div class="data-form-group" id="passwordContainer">
<label for="password" class="data-form-label" th:text="#{password}">Password</label>
<input type="password" class="data-form-control" name="password" id="password" required>
</div>
<div class="data-form-group">
<label for="role" class="data-form-label" th:text="#{adminUserSettings.role}">Role</label>
<select name="role" class="data-form-control" id="role" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<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" 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">
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user to change username/password on login</label>
</div>
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
</form>
</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 class="modal-footer"></div>
</div>
</form>
</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>
<!-- Add User Modal end -->
<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) {
// 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]$/;
@ -241,6 +381,7 @@
// 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() {
$('[data-toggle="tooltip"]').tooltip();
@ -261,9 +402,9 @@
}
},
messages: {
username: {
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
},
username: {
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
},
},
errorPlacement: function(error, element) {
if (element.attr("name") === "username") {
@ -280,40 +421,40 @@
});
$('#username').on('input', function() {
var usernameInput = $(this);
var isValid = usernameInput[0].checkValidity();
var errorSpan = $('#usernameError');
var usernameInput = $(this);
var isValid = usernameInput[0].checkValidity();
var errorSpan = $('#usernameError');
if (isValid) {
usernameInput.removeClass('invalid').addClass('valid');
errorSpan.hide();
} else {
usernameInput.removeClass('valid').addClass('invalid');
errorSpan.show();
}
if (isValid) {
usernameInput.removeClass('invalid').addClass('valid');
errorSpan.hide();
} else {
usernameInput.removeClass('valid').addClass('invalid');
errorSpan.show();
}
});
$('#authType').on('change', function() {
var authType = $(this).val();
var passwordField = $('#password');
var passwordFieldContainer = $('#passwordContainer');
var checkboxContainer = $('#checkboxContainer');
var authType = $(this).val();
var passwordField = $('#password');
var passwordFieldContainer = $('#passwordContainer');
var checkboxContainer = $('#checkboxContainer');
if (authType === 'sso') {
passwordField.removeAttr('required');
passwordField.prop('disabled', true).val('');
passwordFieldContainer.slideUp('fast');
checkboxContainer.slideUp('fast');
} else {
passwordField.prop('disabled', false);
passwordField.attr('required', 'required');
passwordFieldContainer.slideDown('fast');
checkboxContainer.slideDown('fast');
}
if (authType === 'sso') {
passwordField.removeAttr('required');
passwordField.prop('disabled', true).val('');
passwordFieldContainer.slideUp('fast');
checkboxContainer.slideUp('fast');
} else {
passwordField.prop('disabled', false);
passwordField.attr('required', 'required');
passwordFieldContainer.slideDown('fast');
checkboxContainer.slideDown('fast');
}
});
});
</script>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
</html>

View File

@ -0,0 +1,82 @@
<!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=#{editTableOfContents.title}, header=#{editTableOfContents.header})}">
</th:block>
<link rel="stylesheet" th:href="@{'/css/edit-table-of-contents.css'}">
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon edit">bookmark_add</span>
<span class="tool-header-text" th:text="#{editTableOfContents.header}"></span>
</div>
<form th:action="@{'/api/v1/general/edit-table-of-contents'}" method="post" enctype="multipart/form-data" id="editTocForm">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="replaceExisting" name="replaceExisting" checked>
<label class="form-check-label" for="replaceExisting"
th:text="#{editTableOfContents.replaceExisting}"></label>
<input type="hidden" name="replaceExisting" value="false" />
</div>
<div class="bookmark-editor">
<h5 th:text="#{editTableOfContents.editorTitle}"></h5>
<p th:text="#{editTableOfContents.editorDesc}"></p>
<div id="bookmarks-container">
<!-- Bookmarks will be added here dynamically -->
</div>
<div class="bookmark-actions">
<button type="button" id="addBookmarkBtn" class="btn btn-outline-primary" th:text="#{editTableOfContents.addBookmark}"></button>
</div>
<!-- Hidden field to store JSON data -->
<input type="hidden" id="bookmarkData" name="bookmarkData" value="[]">
</div>
<p>
<a class="btn btn-outline-primary" data-bs-toggle="collapse" href="#info" role="button"
aria-expanded="false" aria-controls="info" th:text="#{info}"></a>
</p>
<div class="collapse" id="info">
<p th:text="#{editTableOfContents.desc.1}"></p>
<p th:text="#{editTableOfContents.desc.2}"></p>
<p th:text="#{editTableOfContents.desc.3}"></p>
</div>
<br>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{editTableOfContents.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
<script th:src="@{'/js/pages/edit-table-of-contents.js'}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap tooltips
if (typeof $ !== 'undefined') {
$('[data-bs-toggle="tooltip"]').tooltip();
}
});
</script>
</body>
</html>

View File

@ -32,6 +32,10 @@
<label for="removeCertSign" th:text="#{merge.removeCertSign}">Remove digital signature in the merged
file?</label>
</div>
<div class="mb-3">
<input type="checkbox" name="generateToc" id="generateToc">
<label for="generateToc" th:text="#{merge.generateToc}">Generate table of contents in the merged file?</label>
</div>
<div class="mb-3">
<ul id="selectedFiles" class="list-group"></ul>
</div>