mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-06-13 11:05:03 +00:00
team stuff
This commit is contained in:
parent
0a9bfb44ce
commit
84a2bc7ef8
@ -255,7 +255,7 @@ public class AppConfig {
|
|||||||
|
|
||||||
@Bean(name = "disablePixel")
|
@Bean(name = "disablePixel")
|
||||||
public boolean disablePixel() {
|
public boolean disablePixel() {
|
||||||
return Boolean.getBoolean(env.getProperty("DISABLE_PIXEL"));
|
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean(name = "machineType")
|
@Bean(name = "machineType")
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
package stirling.software.proprietary.security;
|
package stirling.software.proprietary.security;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@ -13,7 +14,10 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.enumeration.Role;
|
import stirling.software.common.model.enumeration.Role;
|
||||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||||
|
import stirling.software.proprietary.model.Team;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||||
|
import stirling.software.proprietary.security.service.TeamService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -22,9 +26,8 @@ import stirling.software.proprietary.security.service.UserService;
|
|||||||
public class InitialSecuritySetup {
|
public class InitialSecuritySetup {
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final TeamService teamService;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
private final DatabaseServiceInterface databaseService;
|
private final DatabaseServiceInterface databaseService;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
@ -40,6 +43,7 @@ public class InitialSecuritySetup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userService.migrateOauth2ToSSO();
|
userService.migrateOauth2ToSSO();
|
||||||
|
assignUsersToDefaultTeamIfMissing();
|
||||||
initializeInternalApiUser();
|
initializeInternalApiUser();
|
||||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||||
log.error("Failed to initialize security setup.", e);
|
log.error("Failed to initialize security setup.", e);
|
||||||
@ -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 {
|
private void initializeAdminUser() throws SQLException, UnsupportedProviderException {
|
||||||
String initialUsername =
|
String initialUsername =
|
||||||
applicationProperties.getSecurity().getInitialLogin().getUsername();
|
applicationProperties.getSecurity().getInitialLogin().getUsername();
|
||||||
@ -58,7 +75,9 @@ public class InitialSecuritySetup {
|
|||||||
&& !initialPassword.isEmpty()
|
&& !initialPassword.isEmpty()
|
||||||
&& userService.findByUsernameIgnoreCase(initialUsername).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);
|
log.info("Admin user created: {}", initialUsername);
|
||||||
} else {
|
} else {
|
||||||
createDefaultAdminUser();
|
createDefaultAdminUser();
|
||||||
@ -70,7 +89,9 @@ public class InitialSecuritySetup {
|
|||||||
String defaultPassword = "stirling";
|
String defaultPassword = "stirling";
|
||||||
|
|
||||||
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
|
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
|
||||||
userService.saveUser(defaultUsername, defaultPassword, Role.ADMIN.getRoleId(), true);
|
Team team = teamService.getOrCreateDefaultTeam();
|
||||||
|
userService.saveUser(
|
||||||
|
defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true);
|
||||||
log.info("Default admin user created: {}", defaultUsername);
|
log.info("Default admin user created: {}", defaultUsername);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,10 +99,13 @@ public class InitialSecuritySetup {
|
|||||||
private void initializeInternalApiUser()
|
private void initializeInternalApiUser()
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
||||||
|
Team team = teamService.getOrCreateInternalTeam();
|
||||||
userService.saveUser(
|
userService.saveUser(
|
||||||
Role.INTERNAL_API_USER.getRoleId(),
|
Role.INTERNAL_API_USER.getRoleId(),
|
||||||
UUID.randomUUID().toString(),
|
UUID.randomUUID().toString(),
|
||||||
Role.INTERNAL_API_USER.getRoleId());
|
team,
|
||||||
|
Role.INTERNAL_API_USER.getRoleId(),
|
||||||
|
false);
|
||||||
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
|
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
|
||||||
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
|
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
|
||||||
}
|
}
|
||||||
|
@ -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;
|
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.GitHubProvider;
|
||||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
import stirling.software.common.model.oauth2.GoogleProvider;
|
||||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
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.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.Authority;
|
import stirling.software.proprietary.security.model.Authority;
|
||||||
import stirling.software.proprietary.security.model.SessionEntity;
|
import stirling.software.proprietary.security.model.SessionEntity;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||||
|
|
||||||
@ -57,16 +59,19 @@ public class AccountWebController {
|
|||||||
// Assuming you have a repository for user operations
|
// Assuming you have a repository for user operations
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final boolean runningEE;
|
private final boolean runningEE;
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
|
|
||||||
public AccountWebController(
|
public AccountWebController(
|
||||||
ApplicationProperties applicationProperties,
|
ApplicationProperties applicationProperties,
|
||||||
SessionPersistentRegistry sessionPersistentRegistry,
|
SessionPersistentRegistry sessionPersistentRegistry,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
|
TeamRepository teamRepository,
|
||||||
@Qualifier("runningEE") boolean runningEE) {
|
@Qualifier("runningEE") boolean runningEE) {
|
||||||
this.applicationProperties = applicationProperties;
|
this.applicationProperties = applicationProperties;
|
||||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.runningEE = runningEE;
|
this.runningEE = runningEE;
|
||||||
|
this.teamRepository=teamRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/login")
|
@GetMapping("/login")
|
||||||
@ -210,7 +215,7 @@ public class AccountWebController {
|
|||||||
@GetMapping("/adminSettings")
|
@GetMapping("/adminSettings")
|
||||||
public String showAddUserForm(
|
public String showAddUserForm(
|
||||||
HttpServletRequest request, Model model, Authentication authentication) {
|
HttpServletRequest request, Model model, Authentication authentication) {
|
||||||
List<User> allUsers = userRepository.findAll();
|
List<User> allUsers = userRepository.findAllWithTeam();
|
||||||
Iterator<User> iterator = allUsers.iterator();
|
Iterator<User> iterator = allUsers.iterator();
|
||||||
Map<String, String> roleDetails = Role.getAllRoleDetails();
|
Map<String, String> roleDetails = Role.getAllRoleDetails();
|
||||||
// Map to store session information and user activity status
|
// Map to store session information and user activity status
|
||||||
@ -331,6 +336,9 @@ public class AccountWebController {
|
|||||||
model.addAttribute("activeUsers", activeUsers);
|
model.addAttribute("activeUsers", activeUsers);
|
||||||
model.addAttribute("disabledUsers", disabledUsers);
|
model.addAttribute("disabledUsers", disabledUsers);
|
||||||
|
|
||||||
|
List<Team> allTeams = teamRepository.findAll();
|
||||||
|
model.addAttribute("teams", allTeams);
|
||||||
|
|
||||||
model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers());
|
model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers());
|
||||||
return "adminSettings";
|
return "adminSettings";
|
||||||
}
|
}
|
@ -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 {}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -21,8 +21,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Getter
|
@Getter
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableJpaRepositories(basePackages = "stirling.software.proprietary.security.database.repository")
|
@EnableJpaRepositories(basePackages = {"stirling.software.proprietary.security.database.repository", "stirling.software.proprietary.security.repository"})
|
||||||
@EntityScan({"stirling.software.proprietary.security.model"})
|
@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"})
|
||||||
public class DatabaseConfig {
|
public class DatabaseConfig {
|
||||||
|
|
||||||
public final String DATASOURCE_DEFAULT_URL;
|
public final String DATASOURCE_DEFAULT_URL;
|
||||||
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
@ -32,10 +32,13 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.enumeration.Role;
|
import stirling.software.common.model.enumeration.Role;
|
||||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||||
|
import stirling.software.proprietary.model.Team;
|
||||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||||
|
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||||
|
import stirling.software.proprietary.security.service.TeamService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||||
|
|
||||||
@ -50,6 +53,7 @@ public class UserController {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final SessionPersistentRegistry sessionRegistry;
|
private final SessionPersistentRegistry sessionRegistry;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
|
|
||||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@ -60,7 +64,13 @@ public class UserController {
|
|||||||
return "register";
|
return "register";
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (IllegalArgumentException e) {
|
||||||
return "redirect:/login?messageType=invalidUsername";
|
return "redirect:/login?messageType=invalidUsername";
|
||||||
}
|
}
|
||||||
@ -233,13 +243,14 @@ public class UserController {
|
|||||||
// If the role ID is not valid, redirect with an error message
|
// If the role ID is not valid, redirect with an error message
|
||||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||||
}
|
}
|
||||||
|
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||||
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
||||||
userService.saveUser(username, AuthenticationType.SSO, role);
|
userService.saveUser(username, AuthenticationType.SSO, team, role);
|
||||||
} else {
|
} else {
|
||||||
if (password.isBlank()) {
|
if (password.isBlank()) {
|
||||||
return new RedirectView("/adminSettings?messageType=invalidPassword", true);
|
return new RedirectView("/adminSettings?messageType=invalidPassword", true);
|
||||||
}
|
}
|
||||||
userService.saveUser(username, password, role, forceChange);
|
userService.saveUser(username, password, team, role, forceChange);
|
||||||
}
|
}
|
||||||
return new RedirectView(
|
return new RedirectView(
|
||||||
"/adminSettings", // Redirect to account page after adding the user
|
"/adminSettings", // Redirect to account page after adding the user
|
||||||
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
@ -29,4 +29,20 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
|
|||||||
@Param("expired") boolean expired,
|
@Param("expired") boolean expired,
|
||||||
@Param("lastRequest") Date lastRequest,
|
@Param("lastRequest") Date lastRequest,
|
||||||
@Param("principalName") String principalName);
|
@Param("principalName") String principalName);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT t.id as teamId, MAX(s.lastRequest) as lastActivity "
|
||||||
|
+ "FROM 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);
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.model.Team;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@ -22,4 +23,14 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
Optional<User> findByApiKey(String apiKey);
|
Optional<User> findByApiKey(String apiKey);
|
||||||
|
|
||||||
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
|
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
|
||||||
|
|
||||||
|
@Query("SELECT u FROM User u WHERE u.team IS NULL")
|
||||||
|
List<User> findAllWithoutTeam();
|
||||||
|
|
||||||
|
@Query("SELECT u FROM User u LEFT JOIN FETCH u.team")
|
||||||
|
List<User> findAllWithTeam();
|
||||||
|
|
||||||
|
long countByTeam(Team team);
|
||||||
|
|
||||||
|
List<User> findAllByTeam(Team team);
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ import lombok.Setter;
|
|||||||
import lombok.ToString;
|
import lombok.ToString;
|
||||||
|
|
||||||
import stirling.software.common.model.enumeration.Role;
|
import stirling.software.common.model.enumeration.Role;
|
||||||
|
import stirling.software.proprietary.model.Team;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "users")
|
@Table(name = "users")
|
||||||
@ -57,6 +58,10 @@ public class User implements Serializable {
|
|||||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||||
private Set<Authority> authorities = new HashSet<>();
|
private Set<Authority> authorities = new HashSet<>();
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "team_id")
|
||||||
|
private Team team;
|
||||||
|
|
||||||
@ElementCollection
|
@ElementCollection
|
||||||
@MapKeyColumn(name = "setting_key")
|
@MapKeyColumn(name = "setting_key")
|
||||||
@Lob
|
@Lob
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
package stirling.software.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);
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import org.springframework.context.MessageSource;
|
import org.springframework.context.MessageSource;
|
||||||
import org.springframework.context.i18n.LocaleContextHolder;
|
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.enumeration.Role;
|
||||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||||
import stirling.software.common.service.UserServiceInterface;
|
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.AuthorityRepository;
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||||
import stirling.software.proprietary.security.model.Authority;
|
import stirling.software.proprietary.security.model.Authority;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||||
|
|
||||||
@ -45,7 +48,7 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
|||||||
public class UserService implements UserServiceInterface {
|
public class UserService implements UserServiceInterface {
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final TeamRepository teamRepository;
|
||||||
private final AuthorityRepository authorityRepository;
|
private final AuthorityRepository authorityRepository;
|
||||||
|
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
@ -162,7 +165,7 @@ public class UserService implements UserServiceInterface {
|
|||||||
|
|
||||||
public void saveUser(String username, AuthenticationType authenticationType)
|
public void saveUser(String username, AuthenticationType authenticationType)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
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) {
|
private User saveUser(Optional<User> user, String apiKey) {
|
||||||
@ -173,71 +176,98 @@ public class UserService implements UserServiceInterface {
|
|||||||
throw new UsernameNotFoundException("User not found");
|
throw new UsernameNotFoundException("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, AuthenticationType authenticationType, String role)
|
public User saveUser(
|
||||||
|
String username, AuthenticationType authenticationType, Long teamId, String role)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!isUsernameValid(username)) {
|
return saveUserCore(
|
||||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
username, // username
|
||||||
}
|
null, // password
|
||||||
User user = new User();
|
authenticationType, // authenticationType
|
||||||
user.setUsername(username);
|
teamId, // teamId
|
||||||
user.setEnabled(true);
|
null, // team
|
||||||
user.setFirstLogin(false);
|
role, // role
|
||||||
user.addAuthority(new Authority(role, user));
|
false, // firstLogin
|
||||||
user.setAuthenticationType(authenticationType);
|
true // enabled
|
||||||
userRepository.save(user);
|
);
|
||||||
databaseService.exportDatabase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, String password)
|
public User saveUser(
|
||||||
|
String username, AuthenticationType authenticationType, Team team, String role)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!isUsernameValid(username)) {
|
return saveUserCore(
|
||||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
username, // username
|
||||||
}
|
null, // password
|
||||||
User user = new User();
|
authenticationType, // authenticationType
|
||||||
user.setUsername(username);
|
null, // teamId
|
||||||
user.setPassword(passwordEncoder.encode(password));
|
team, // team
|
||||||
user.setEnabled(true);
|
role, // role
|
||||||
user.setAuthenticationType(AuthenticationType.WEB);
|
false, // firstLogin
|
||||||
user.addAuthority(new Authority(Role.USER.getRoleId(), user));
|
true // enabled
|
||||||
userRepository.save(user);
|
);
|
||||||
databaseService.exportDatabase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveUser(String username, String password, String role, boolean firstLogin)
|
public User saveUser(String username, String password, Long teamId)
|
||||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!isUsernameValid(username)) {
|
return saveUserCore(
|
||||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
username, // username
|
||||||
}
|
password, // password
|
||||||
User user = new User();
|
AuthenticationType.WEB, // authenticationType
|
||||||
user.setUsername(username);
|
teamId, // teamId
|
||||||
user.setPassword(passwordEncoder.encode(password));
|
null, // team
|
||||||
user.addAuthority(new Authority(role, user));
|
Role.USER.getRoleId(), // role
|
||||||
user.setEnabled(true);
|
false, // firstLogin
|
||||||
user.setAuthenticationType(AuthenticationType.WEB);
|
true // enabled
|
||||||
user.setFirstLogin(firstLogin);
|
);
|
||||||
userRepository.save(user);
|
|
||||||
databaseService.exportDatabase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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 {
|
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||||
if (!isUsernameValid(username)) {
|
return saveUserCore(
|
||||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
username, // username
|
||||||
}
|
password, // password
|
||||||
User user = new User();
|
AuthenticationType.WEB, // authenticationType
|
||||||
user.setUsername(username);
|
teamId, // teamId
|
||||||
user.setPassword(passwordEncoder.encode(password));
|
null, // team
|
||||||
user.addAuthority(new Authority(Role.USER.getRoleId(), user));
|
role, // role
|
||||||
user.setEnabled(enabled);
|
firstLogin, // firstLogin
|
||||||
user.setAuthenticationType(AuthenticationType.WEB);
|
true // enabled
|
||||||
user.setFirstLogin(firstLogin);
|
);
|
||||||
userRepository.save(user);
|
}
|
||||||
databaseService.exportDatabase();
|
|
||||||
|
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) {
|
public void deleteUser(String username) {
|
||||||
@ -345,6 +375,111 @@ public class UserService implements UserServiceInterface {
|
|||||||
return passwordEncoder.matches(currentPassword, user.getPassword());
|
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) {
|
public boolean isUsernameValid(String username) {
|
||||||
// Checks whether the simple username is formatted correctly
|
// Checks whether the simple username is formatted correctly
|
||||||
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
||||||
@ -464,7 +599,6 @@ public class UserService implements UserServiceInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public long getTotalUsersCount() {
|
public long getTotalUsersCount() {
|
||||||
// Count all users in the database
|
// Count all users in the database
|
||||||
long userCount = userRepository.count();
|
long userCount = userRepository.count();
|
||||||
@ -474,4 +608,12 @@ public class UserService implements UserServiceInterface {
|
|||||||
}
|
}
|
||||||
return userCount;
|
return userCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<User> getUsersWithoutTeam() {
|
||||||
|
return userRepository.findAllWithoutTeam();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveAll(List<User> users) {
|
||||||
|
userRepository.saveAll(users);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
361
proprietary/src/main/resources/static/css/modern-tables.css
Normal file
361
proprietary/src/main/resources/static/css/modern-tables.css
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
/* modern-tables.css - Professional styling for data tables and related elements */
|
||||||
|
|
||||||
|
/* Main container - Reduced max-width from 1100px to 900px */
|
||||||
|
.data-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
background-color: var(--md-sys-color-surface-container-lowest);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: 0 2px 12px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel / Card */
|
||||||
|
.data-panel {
|
||||||
|
background-color: var(--md-sys-color-surface);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.data-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background-color: var(--md-sys-color-surface-variant);
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background-color: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.data-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: var(--md-sys-color-surface-container-low);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons container */
|
||||||
|
.data-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Can add these classes for different alignments */
|
||||||
|
.data-actions-start {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-actions-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styling */
|
||||||
|
.data-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed button colors - normal state has more contrast now */
|
||||||
|
.data-btn-primary {
|
||||||
|
background-color: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-btn-primary:hover {
|
||||||
|
background-color: var(--md-sys-color-primary-container);
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-btn-secondary {
|
||||||
|
background-color: var(--md-sys-color-secondary);
|
||||||
|
color: var(--md-sys-color-on-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-btn-secondary:hover {
|
||||||
|
background-color: var(--md-sys-color-secondary-container);
|
||||||
|
color: var(--md-sys-color-secondary);
|
||||||
|
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-btn-danger {
|
||||||
|
background-color: var(--md-sys-color-error);
|
||||||
|
color: var(--md-sys-color-on-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-btn-danger:hover {
|
||||||
|
background-color: var(--md-sys-color-error-container);
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-btn-sm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon button */
|
||||||
|
.data-icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed icon button colors */
|
||||||
|
.data-icon-btn-primary {
|
||||||
|
background-color: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-icon-btn-primary:hover {
|
||||||
|
background-color: var(--md-sys-color-primary-container);
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-icon-btn-danger {
|
||||||
|
background-color: var(--md-sys-color-error);
|
||||||
|
color: var(--md-sys-color-on-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-icon-btn-danger:hover {
|
||||||
|
background-color: var(--md-sys-color-error-container);
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styling */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th:first-child {
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th:last-child {
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background-color: rgba(var(--md-sys-color-surface-variant-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table action cells */
|
||||||
|
.data-action-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-action-cell-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-action-cell-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.data-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-status-success {
|
||||||
|
background-color: var(--md-sys-color-tertiary-container);
|
||||||
|
color: var(--md-sys-color-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-status-danger {
|
||||||
|
background-color: var(--md-sys-color-error-container);
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-status-warning {
|
||||||
|
background-color: var(--md-sys-color-secondary-container);
|
||||||
|
color: var(--md-sys-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-status-info {
|
||||||
|
background-color: var(--md-sys-color-primary-container);
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats/Info container */
|
||||||
|
.data-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stat-card {
|
||||||
|
background-color: var(--md-sys-color-surface-variant);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 2px 8px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-stat-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section title */
|
||||||
|
.data-section-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1.5rem 0 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state styling */
|
||||||
|
.data-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-empty-text {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styling */
|
||||||
|
.data-modal {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-modal-header {
|
||||||
|
background-color: var(--md-sys-color-surface-variant);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form elements */
|
||||||
|
.data-form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid
|
||||||
|
}
|
@ -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>
|
122
proprietary/src/main/resources/templates/enterprise/teams.html
Normal file
122
proprietary/src/main/resources/templates/enterprise/teams.html
Normal 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>
|
@ -2,6 +2,6 @@ plugins {
|
|||||||
// Apply the foojay-resolver plugin to allow automatic download of JDKs
|
// Apply the foojay-resolver plugin to allow automatic download of JDKs
|
||||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||||
}
|
}
|
||||||
rootProject.name = 'Stirling-PDF'
|
rootProject.name = 'Stirling PDF'
|
||||||
|
|
||||||
include 'stirling-pdf', 'common', 'proprietary'
|
include 'stirling-pdf', 'common', 'proprietary'
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -15,6 +15,8 @@ import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
|||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||||
import org.apache.pdfbox.pdmodel.PDPage;
|
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.PDAcroForm;
|
||||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
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")
|
@PostMapping(consumes = "multipart/form-data", value = "/merge-pdfs")
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Merge multiple PDF files into one",
|
summary = "Merge multiple PDF files into one",
|
||||||
@ -124,6 +166,7 @@ public class MergeController {
|
|||||||
PDDocument mergedDocument = null;
|
PDDocument mergedDocument = null;
|
||||||
|
|
||||||
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
|
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
|
||||||
|
boolean generateToc = request.isGenerateToc();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
MultipartFile[] files = request.getFileInput();
|
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
|
// Save the modified document to a new ByteArrayOutputStream
|
||||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -32,4 +32,11 @@ public class MergePdfsRequest extends MultiplePDFFiles {
|
|||||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||||
defaultValue = "true")
|
defaultValue = "true")
|
||||||
private Boolean removeCertSign;
|
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;
|
||||||
}
|
}
|
||||||
|
@ -127,6 +127,7 @@ enterpriseEdition.button=Upgrade to Pro
|
|||||||
enterpriseEdition.warning=This feature is only available to Pro users.
|
enterpriseEdition.warning=This feature is only available to Pro users.
|
||||||
enterpriseEdition.yamlAdvert=Stirling PDF Pro supports YAML configuration files and other SSO features.
|
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.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.lastRequest=Last Request
|
||||||
adminUserSettings.usage=View Usage
|
adminUserSettings.usage=View Usage
|
||||||
|
|
||||||
|
adminUserSettings.teams=View/Edit Teams
|
||||||
|
adminUserSettings.team=Team
|
||||||
|
adminUserSettings.manageTeams=Manage Teams
|
||||||
|
adminUserSettings.createTeam=Create Team
|
||||||
|
adminUserSettings.teamName=Team Name
|
||||||
|
adminUserSettings.teamExists=Team already exists
|
||||||
|
adminUserSettings.teamCreated=Team created successfully
|
||||||
|
adminUserSettings.teamChanged=User's team was updated
|
||||||
|
adminUserSettings.totalMembers=Total Members
|
||||||
|
|
||||||
|
teamCreated=Team created successfully
|
||||||
|
teamExists=A team with that name already exists
|
||||||
|
teamNameExists=Another team with that name already exists
|
||||||
|
teamNotFound=Team not found
|
||||||
|
teamDeleted=Team deleted
|
||||||
|
teamHasUsers=Cannot delete a team with users assigned
|
||||||
|
teamRenamed=Team renamed successfully
|
||||||
|
|
||||||
endpointStatistics.title=Endpoint Statistics
|
endpointStatistics.title=Endpoint Statistics
|
||||||
endpointStatistics.header=Endpoint Statistics
|
endpointStatistics.header=Endpoint Statistics
|
||||||
endpointStatistics.top10=Top 10
|
endpointStatistics.top10=Top 10
|
||||||
@ -1010,6 +1029,7 @@ merge.header=Merge multiple PDFs (2+)
|
|||||||
merge.sortByName=Sort by name
|
merge.sortByName=Sort by name
|
||||||
merge.sortByDate=Sort by date
|
merge.sortByDate=Sort by date
|
||||||
merge.removeCertSign=Remove digital signature in the merged file?
|
merge.removeCertSign=Remove digital signature in the merged file?
|
||||||
|
merge.generateToc=Generate table of contents in the merged file?
|
||||||
merge.submit=Merge
|
merge.submit=Merge
|
||||||
|
|
||||||
|
|
||||||
@ -1459,3 +1479,19 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
|
|||||||
cookieBanner.preferencesModal.analytics.title=Analytics
|
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.
|
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
|
||||||
|
|
||||||
|
@ -127,6 +127,7 @@ enterpriseEdition.button=Upgrade to Pro
|
|||||||
enterpriseEdition.warning=This feature is only available to Pro users.
|
enterpriseEdition.warning=This feature is only available to Pro users.
|
||||||
enterpriseEdition.yamlAdvert=Stirling PDF Pro supports YAML configuration files and other SSO features.
|
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.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.sortByName=Sort by name
|
||||||
merge.sortByDate=Sort by date
|
merge.sortByDate=Sort by date
|
||||||
merge.removeCertSign=Remove digital signature in the merged file?
|
merge.removeCertSign=Remove digital signature in the merged file?
|
||||||
|
merge.generateToc=Generate table of contents in the merged file?
|
||||||
merge.submit=Merge
|
merge.submit=Merge
|
||||||
|
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<th:block th:insert="~{fragments/common :: head(title=#{account.title})}"></th:block>
|
<th:block th:insert="~{fragments/common :: head(title=#{account.title})}"></th:block>
|
||||||
|
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@ -9,353 +10,462 @@
|
|||||||
<div id="page-container">
|
<div id="page-container">
|
||||||
<div id="content-wrap">
|
<div id="content-wrap">
|
||||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||||
<br><br>
|
|
||||||
<div class="container">
|
<div class="data-container">
|
||||||
<div class="row justify-content-center">
|
<div class="data-panel">
|
||||||
<div class="col-md-9 bg-card">
|
<div class="data-header">
|
||||||
<div class="tool-header">
|
<h1 class="data-title">
|
||||||
<span class="material-symbols-rounded tool-header-icon organize">settings_account_box</span>
|
<span class="data-icon">
|
||||||
<span class="tool-header-text" th:text="#{account.accountSettings}">User Settings</span>
|
<span class="material-symbols-rounded">settings_account_box</span>
|
||||||
</div>
|
</span>
|
||||||
|
<span th:text="#{account.accountSettings}">User Settings</span>
|
||||||
<!-- User Settings Title -->
|
</h1>
|
||||||
<th:block th:if="${messageType}">
|
</div>
|
||||||
<div class="alert alert-danger">
|
|
||||||
|
<div class="data-body">
|
||||||
|
<div th:if="${messageType}" class="alert alert-danger data-mb-3">
|
||||||
<span th:text="#{${messageType}}">Default message if not found</span>
|
<span th:text="#{${messageType}}">Default message if not found</span>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
|
||||||
|
<div th:if="${error}" class="alert alert-danger data-mb-3" role="alert">
|
||||||
<!-- At the top of the user settings -->
|
|
||||||
<h3 class="text-center"><span th:text="#{welcome} + ' ' + ${username}">User</span>!</h3>
|
|
||||||
<th:block th:if="${error}">
|
|
||||||
<div class="alert alert-danger" role="alert">
|
|
||||||
<span th:text="${error}">Error Message</span>
|
<span th:text="${error}">Error Message</span>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
|
||||||
|
<!-- Admin Settings Banner (for admins only) -->
|
||||||
<!-- Change Username Form -->
|
<div th:if="${role == 'ROLE_ADMIN'}" class="data-panel data-mb-3" style="background-color: var(--md-sys-color-secondary-container);">
|
||||||
|
<div class="data-body" style="display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; background-color: var(--md-sys-color-secondary-container);">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 2rem; color: var(--md-sys-color-secondary);">
|
||||||
|
admin_panel_settings
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="margin: 0; color: var(--md-sys-color-secondary);" th:text="#{account.adminTitle}">Administrator Tools</h4>
|
||||||
|
<p style="margin: 0.25rem 0 0 0; color: var(--md-sys-color-secondary);" th:text="#{account.adminNotif}">You have admin privileges. Access system settings and user management.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="data-btn" th:href="@{'/adminSettings'}" role="button" target="_blank"
|
||||||
|
style="background-color: var(--md-sys-color-secondary); color: var(--md-sys-color-on-secondary); display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.625rem 1.25rem; border-radius: 0.5rem; font-weight: 500; border: none; cursor: pointer; text-decoration: none;">
|
||||||
|
<span class="material-symbols-rounded">admin_panel_settings</span>
|
||||||
|
<span th:text="#{account.adminSettings}">Admin Settings</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Management Buttons -->
|
||||||
<th:block th:if="not ${oAuth2Login} or not ${saml2Login}">
|
<th:block th:if="not ${oAuth2Login} or not ${saml2Login}">
|
||||||
<h4 th:text="#{account.changeUsername}">Change Username?</h4>
|
<div class="data-section-title">Account Management</div>
|
||||||
<form id="formsavechangeusername" class="bg-card mt-4 mb-4" th:action="@{'/api/v1/user/change-username'}" method="post">
|
<div class="data-actions data-actions-start data-mb-3">
|
||||||
<div class="mb-3">
|
<button class="data-btn data-btn-primary" data-bs-toggle="modal" data-bs-target="#changeUsernameModal">
|
||||||
<label for="newUsername" th:text="#{account.newUsername}">Change Username</label>
|
<span class="material-symbols-rounded">edit</span>
|
||||||
<input type="text" class="form-control" name="newUsername" id="newUsername" th:placeholder="#{account.newUsername}">
|
<span th:text="#{account.changeUsername}">Change Username</span>
|
||||||
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
</button>
|
||||||
|
<button class="data-btn data-btn-primary" data-bs-toggle="modal" data-bs-target="#changePasswordModal">
|
||||||
|
<span class="material-symbols-rounded">key</span>
|
||||||
|
<span th:text="#{account.changePassword}">Change Password</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
|
||||||
<label for="currentPasswordChangeUsername" th:text="#{password}">Password</label>
|
|
||||||
<input type="password" class="form-control" name="currentPasswordChangeUsername" id="currentPasswordChangeUsername" th:placeholder="#{password}">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<button type="submit" class="btn btn-primary" th:text="#{account.changeUsername}">Change Username</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<!-- Change Password Form -->
|
<!-- API Key Section -->
|
||||||
<th:block th:if="not ${oAuth2Login} or not ${saml2Login}">
|
<div class="data-section-title" th:text="#{account.yourApiKey}">API Key</div>
|
||||||
<h4 th:text="#{account.changePassword}">Change Password?</h4>
|
<div class="data-panel data-mb-3">
|
||||||
<form id="formsavechangepassword" class="bg-card mt-4 mb-4" th:action="@{'/api/v1/user/change-password'}" method="post">
|
<div class="data-header">
|
||||||
<div class="mb-3">
|
<h5 class="data-title">
|
||||||
<label for="currentPassword" th:text="#{account.oldPassword}">Old Password</label>
|
<span class="data-icon">
|
||||||
<input type="password" class="form-control" name="currentPassword" id="currentPassword" th:placeholder="#{account.oldPassword}">
|
<span class="material-symbols-rounded">key</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{account.yourApiKey}">API Key</span>
|
||||||
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="data-body">
|
||||||
<label for="newPassword" th:text="#{account.newPassword}">New Password</label>
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<input type="password" class="form-control" name="newPassword" id="newPassword" th:placeholder="#{account.newPassword}">
|
<input type="password" class="data-form-control" id="apiKey" th:placeholder="#{account.yourApiKey}" readonly style="flex: 1;">
|
||||||
</div>
|
<button class="data-btn data-btn-secondary" id="copyBtn" type="button" onclick="copyToClipboard()" title="Copy to clipboard">
|
||||||
<div class="mb-3">
|
<span class="material-symbols-rounded">content_copy</span>
|
||||||
<label for="confirmNewPassword" th:text="#{account.confirmNewPassword}">Confirm New Password</label>
|
</button>
|
||||||
<input type="password" class="form-control" name="confirmNewPassword" id="confirmNewPassword" th:placeholder="#{account.confirmNewPassword}">
|
<button class="data-btn data-btn-secondary" id="showBtn" type="button" onclick="showApiKey()" title="Show/hide API key">
|
||||||
</div>
|
<span class="material-symbols-rounded" id="eyeIcon">visibility</span>
|
||||||
<div class="mb-3">
|
</button>
|
||||||
<span id="confirmPasswordError" style="display: none;" th:text="#{confirmPasswordErrorMessage}">New Password and Confirm New Password must match.</span>
|
<button class="data-btn data-btn-secondary" id="refreshBtn" type="button" onclick="refreshApiKey()" title="Refresh API key">
|
||||||
<button type="submit" class="btn btn-primary" th:text="#{account.changePassword}">Change Password</button>
|
<span class="material-symbols-rounded">refresh</span>
|
||||||
</div>
|
</button>
|
||||||
</form>
|
|
||||||
</th:block>
|
|
||||||
|
|
||||||
<!-- API Key Form -->
|
|
||||||
<h4 th:text="#{account.yourApiKey}">API Key</h4>
|
|
||||||
<div class="card mt-4 mb-4">
|
|
||||||
<div class="card-header" th:text="#{account.yourApiKey}"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input type="password" class="form-control" id="apiKey" th:placeholder="#{account.yourApiKey}" readonly>
|
|
||||||
<div class="input-group-append">
|
|
||||||
<button class="btn btn-secondary" id="copyBtn" type="button" onclick="copyToClipboard()">
|
|
||||||
<span class="material-symbols-rounded">
|
|
||||||
content_copy
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" id="showBtn" type="button" onclick="showApiKey()">
|
|
||||||
<span class="material-symbols-rounded" id="eyeIcon">
|
|
||||||
visibility
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" id="refreshBtn" type="button" onclick="refreshApiKey()">
|
|
||||||
<span class="material-symbols-rounded">
|
|
||||||
refresh
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script th:inline="javascript">
|
|
||||||
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
|
||||||
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
|
||||||
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
|
||||||
|
|
||||||
// Regular expression for email addresses: Max. 320 characters, with RFC-like validation
|
<!-- Settings Sync Section -->
|
||||||
const regexEmail = /^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.[A-Za-z]{2,})$/;
|
<div class="data-section-title" th:text="#{account.syncTitle}">Sync browser settings with Account</div>
|
||||||
|
<div class="data-panel data-mb-3">
|
||||||
// Check if the field is optional or meets the requirements
|
<div class="data-header">
|
||||||
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
<h5 class="data-title">
|
||||||
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
<span class="data-icon">
|
||||||
$(document).ready(function() {
|
<span class="material-symbols-rounded">sync</span>
|
||||||
$.validator.addMethod("passwordMatch", function(value, element) {
|
</span>
|
||||||
return $('#newPassword').val() === $('#confirmNewPassword').val();
|
<span th:text="#{account.settingsCompare}">Settings Comparison</span>
|
||||||
}, /*[[#{confirmPasswordErrorMessage}]]*/ "New Password and Confirm New Password must match.");
|
</h5>
|
||||||
$('#formsavechangepassword').validate({
|
</div>
|
||||||
rules: {
|
<div class="data-body">
|
||||||
currentPassword: {
|
<div class="table-responsive">
|
||||||
required: true
|
<table id="settingsTable" class="data-table">
|
||||||
},
|
<thead>
|
||||||
newPassword: {
|
<tr>
|
||||||
required: true
|
<th scope="col" th:text="#{account.property}">Property</th>
|
||||||
},
|
<th scope="col" th:text="#{account.accountSettings}">Account Setting</th>
|
||||||
confirmNewPassword: {
|
<th scope="col" th:text="#{account.webBrowserSettings}">Web Browser Setting</th>
|
||||||
required: true,
|
</tr>
|
||||||
passwordMatch: true
|
</thead>
|
||||||
},
|
<tbody>
|
||||||
errorPlacement: function(error, element) {
|
<!-- This will be dynamically populated by JavaScript -->
|
||||||
if ($(element).attr("name") === "newPassword" || $(element).attr("name") === "confirmNewPassword") {
|
</tbody>
|
||||||
$("#confirmPasswordError").text(error.text()).show();
|
</table>
|
||||||
} else {
|
</div>
|
||||||
error.insertAfter(element);
|
|
||||||
}
|
<div class="data-actions data-mt-3">
|
||||||
},
|
<button id="syncToBrowser" class="data-btn data-btn-primary">
|
||||||
success: function(label, element) {
|
<span class="material-symbols-rounded">cloud_download</span>
|
||||||
if ($(element).attr("name") === "newPassword" || $(element).attr("name") === "confirmNewPassword") {
|
<span th:text="#{account.syncToBrowser}">Sync Account -> Browser</span>
|
||||||
$("#confirmPasswordError").hide();
|
</button>
|
||||||
}
|
<button id="syncToAccount" class="data-btn data-btn-secondary">
|
||||||
}
|
<span class="material-symbols-rounded">cloud_upload</span>
|
||||||
}
|
<span th:text="#{account.syncToAccount}">Sync Account <- Browser</span>
|
||||||
});
|
</button>
|
||||||
|
</div>
|
||||||
$('#formsavechangeusername').validate({
|
|
||||||
rules: {
|
|
||||||
newUsername: {
|
|
||||||
required: true,
|
|
||||||
usernamePattern: true
|
|
||||||
},
|
|
||||||
currentPasswordChangeUsername: {
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
newUsername: {
|
|
||||||
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
errorPlacement: function(error, element) {
|
|
||||||
if (element.attr("name") === "newUsername") {
|
|
||||||
$("#usernameError").text(error.text()).show();
|
|
||||||
} else {
|
|
||||||
error.insertAfter(element);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: function(label, element) {
|
|
||||||
if ($(element).attr("name") === "newUsername") {
|
|
||||||
$("#usernameError").hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script th:inline="javascript">
|
|
||||||
function copyToClipboard() {
|
|
||||||
const apiKeyElement = document.getElementById("apiKey");
|
|
||||||
apiKeyElement.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
}
|
|
||||||
|
|
||||||
function showApiKey() {
|
|
||||||
const apiKeyElement = document.getElementById("apiKey");
|
|
||||||
const copyBtn = document.getElementById("copyBtn");
|
|
||||||
const eyeIcon = document.getElementById("eyeIcon");
|
|
||||||
if (apiKeyElement.type === "password") {
|
|
||||||
apiKeyElement.type = "text";
|
|
||||||
eyeIcon.textContent = "visibility_off";
|
|
||||||
copyBtn.disabled = false; // Enable copy button when API key is visible
|
|
||||||
} else {
|
|
||||||
apiKeyElement.type = "password";
|
|
||||||
eyeIcon.textContent = "visibility";
|
|
||||||
copyBtn.disabled = true; // Disable copy button when API key is hidden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async function() {
|
|
||||||
showApiKey();
|
|
||||||
try {
|
|
||||||
/*<![CDATA[*/
|
|
||||||
const urlGetApiKey = /*[[@{/api/v1/user/get-api-key}]]*/ "/api/v1/user/get-api-key";
|
|
||||||
/*]]>*/
|
|
||||||
let response = await window.fetchWithCsrf(urlGetApiKey, { method: 'POST' });
|
|
||||||
if (response.status === 200) {
|
|
||||||
let apiKey = await response.text();
|
|
||||||
manageUIState(apiKey);
|
|
||||||
} else {
|
|
||||||
manageUIState(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('There was an error:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function refreshApiKey() {
|
|
||||||
try {
|
|
||||||
/*<![CDATA[*/
|
|
||||||
const urlUpdateApiKey = /*[[@{/api/v1/user/update-api-key}]]*/ "/api/v1/user/update-api-key";
|
|
||||||
/*]]>*/
|
|
||||||
let response = await window.fetchWithCsrf(urlUpdateApiKey, { method: 'POST' });
|
|
||||||
if (response.status === 200) {
|
|
||||||
let apiKey = await response.text();
|
|
||||||
manageUIState(apiKey);
|
|
||||||
document.getElementById("apiKey").type = 'text';
|
|
||||||
document.getElementById("copyBtn").disabled = false;
|
|
||||||
} else {
|
|
||||||
alert('Error refreshing API key.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('There was an error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function manageUIState(apiKey) {
|
|
||||||
const apiKeyElement = document.getElementById("apiKey");
|
|
||||||
const showBtn = document.getElementById("showBtn");
|
|
||||||
const copyBtn = document.getElementById("copyBtn");
|
|
||||||
|
|
||||||
if (apiKey && apiKey.trim().length > 0) {
|
|
||||||
apiKeyElement.value = apiKey;
|
|
||||||
showBtn.disabled = false;
|
|
||||||
copyBtn.disabled = false;
|
|
||||||
} else {
|
|
||||||
apiKeyElement.value = "";
|
|
||||||
showBtn.disabled = true;
|
|
||||||
copyBtn.disabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h4 th:text="#{account.syncTitle}">Sync browser settings with Account</h4>
|
|
||||||
<div class="bg-card container mt-4">
|
|
||||||
<h3 th:text="#{account.settingsCompare}">Settings Comparison:</h3>
|
|
||||||
<table id="settingsTable" class="table table-bordered table-sm table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" th:text="#{account.property}">Property</th>
|
|
||||||
<th scope="col" th:text="#{account.accountSettings}">Account Setting</th>
|
|
||||||
<th scope="col" th:text="#{account.webBrowserSettings}">Web Browser Setting</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<!-- This will be dynamically populated by JavaScript -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="buttons-container mt-3 text-center">
|
|
||||||
<button id="syncToBrowser" class="btn btn-primary btn-sm" th:text="#{account.syncToBrowser}">Sync Account -> Browser</button>
|
|
||||||
<button id="syncToAccount" class="btn btn-secondary btn-sm" th:text="#{account.syncToAccount}">Sync Account <- Browser</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script th:inline="javascript">
|
|
||||||
document.addEventListener("DOMContentLoaded", async function() {
|
|
||||||
const settingsTableBody = document.querySelector("#settingsTable tbody");
|
|
||||||
|
|
||||||
/*<![CDATA[*/
|
|
||||||
var accountSettingsString = /*[[${settings}]]*/ {};
|
|
||||||
/*]]>*/
|
|
||||||
var accountSettings = JSON.parse(accountSettingsString);
|
|
||||||
|
|
||||||
let allKeys = new Set([...Object.keys(accountSettings), ...Object.keys(localStorage)]);
|
|
||||||
|
|
||||||
allKeys.forEach(key => {
|
|
||||||
if(key === 'debug' || key === '0' || key === '1' || key.includes('pdfjs') || key.includes('posthog') || key.includes('pageViews')) return; // Ignoring specific keys
|
|
||||||
|
|
||||||
const accountValue = accountSettings[key] || '-';
|
|
||||||
const browserValue = localStorage.getItem(key) || '-';
|
|
||||||
|
|
||||||
const row = settingsTableBody.insertRow();
|
|
||||||
const propertyCell = row.insertCell(0);
|
|
||||||
const accountCell = row.insertCell(1);
|
|
||||||
const browserCell = row.insertCell(2);
|
|
||||||
|
|
||||||
propertyCell.textContent = key;
|
|
||||||
accountCell.textContent = accountValue;
|
|
||||||
browserCell.textContent = browserValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('syncToBrowser').addEventListener('click', function() {
|
|
||||||
// First, clear the local storage
|
|
||||||
localStorage.clear();
|
|
||||||
|
|
||||||
// Then, set the account settings to local storage
|
|
||||||
for (let key in accountSettings) {
|
|
||||||
if(key !== 'debug' && key !== '0' && key !== '1' && !key.includes('pdfjs') && !key.includes('posthog') && !key.includes('pageViews')) { // Only sync non-ignored keys
|
|
||||||
localStorage.setItem(key, accountSettings[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
location.reload(); // Refresh the page after sync
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('syncToAccount').addEventListener('click', async function() {
|
|
||||||
/*<![CDATA[*/
|
|
||||||
const urlUpdateUserSettings = /*[[@{/api/v1/user/updateUserSettings}]]*/ "/api/v1/user/updateUserSettings";
|
|
||||||
/*]]>*/
|
|
||||||
|
|
||||||
let settings = {};
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if(key !== 'debug' && key !== '0' && key !== '1' && !key.includes('pdfjs') && !key.includes('posthog') && !key.includes('pageViews')) {
|
|
||||||
settings[key] = localStorage.getItem(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await window.fetchWithCsrf(urlUpdateUserSettings, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(settings)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
alert('Error syncing settings to account');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error syncing settings to account');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<div class="mb-3 mt-4 text-center">
|
|
||||||
<a th:href="@{'/logout'}" role="button" class="btn btn-danger" th:text="#{account.signOut}">Sign Out</a>
|
|
||||||
<a th:if="${role == 'ROLE_ADMIN'}" class="btn btn-info" th:href="@{'/adminSettings'}" role="button" th:text="#{account.adminSettings}" target="_blank">Admin Settings</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Username Modal -->
|
||||||
|
<div class="modal fade" id="changeUsernameModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<form id="formsavechangeusername" th:action="@{'/api/v1/user/change-username'}" method="post" class="modal-content data-modal">
|
||||||
|
<div class="data-modal-header">
|
||||||
|
<h5 class="data-modal-title">
|
||||||
|
<span class="data-icon">
|
||||||
|
<span class="material-symbols-rounded">edit</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{account.changeUsername}">Change Username</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="data-modal-body">
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="newUsername" class="data-form-label" th:text="#{account.newUsername}">New Username</label>
|
||||||
|
<input type="text" class="data-form-control" name="newUsername" id="newUsername" th:placeholder="#{account.newUsername}">
|
||||||
|
<span id="usernameError" style="display: none; color: var(--md-sys-color-error);" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="currentPasswordChangeUsername" class="data-form-label" th:text="#{password}">Password</label>
|
||||||
|
<input type="password" class="data-form-control" name="currentPasswordChangeUsername" id="currentPasswordChangeUsername" th:placeholder="#{password}">
|
||||||
|
</div>
|
||||||
|
<div class="data-form-actions">
|
||||||
|
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
<span th:text="#{cancel}">Cancel</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="data-btn data-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">check</span>
|
||||||
|
<span th:text="#{account.changeUsername}">Change Username</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Modal -->
|
||||||
|
<div class="modal fade" id="changePasswordModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<form id="formsavechangepassword" th:action="@{'/api/v1/user/change-password'}" method="post" class="modal-content data-modal">
|
||||||
|
<div class="data-modal-header">
|
||||||
|
<h5 class="data-modal-title">
|
||||||
|
<span class="data-icon">
|
||||||
|
<span class="material-symbols-rounded">key</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{account.changePassword}">Change Password</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="data-modal-body">
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="currentPassword" class="data-form-label" th:text="#{account.oldPassword}">Old Password</label>
|
||||||
|
<input type="password" class="data-form-control" name="currentPassword" id="currentPassword" th:placeholder="#{account.oldPassword}">
|
||||||
|
</div>
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="newPassword" class="data-form-label" th:text="#{account.newPassword}">New Password</label>
|
||||||
|
<input type="password" class="data-form-control" name="newPassword" id="newPassword" th:placeholder="#{account.newPassword}">
|
||||||
|
</div>
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="confirmNewPassword" class="data-form-label" th:text="#{account.confirmNewPassword}">Confirm New Password</label>
|
||||||
|
<input type="password" class="data-form-control" name="confirmNewPassword" id="confirmNewPassword" th:placeholder="#{account.confirmNewPassword}">
|
||||||
|
<span id="confirmPasswordError" style="display: none; color: var(--md-sys-color-error);" th:text="#{confirmPasswordErrorMessage}">New Password and Confirm New Password must match.</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-form-actions">
|
||||||
|
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
<span th:text="#{cancel}">Cancel</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="data-btn data-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">check</span>
|
||||||
|
<span th:text="#{account.changePassword}">Change Password</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript for validation -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
||||||
|
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
||||||
|
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
||||||
|
|
||||||
|
// Regular expression for email addresses: Max. 320 characters, with RFC-like validation
|
||||||
|
const regexEmail = /^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.[A-Za-z]{2,})$/;
|
||||||
|
|
||||||
|
// Check if the field is optional or meets the requirements
|
||||||
|
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
||||||
|
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
$.validator.addMethod("passwordMatch", function(value, element) {
|
||||||
|
return $('#newPassword').val() === $('#confirmNewPassword').val();
|
||||||
|
}, /*[[#{confirmPasswordErrorMessage}]]*/ "New Password and Confirm New Password must match.");
|
||||||
|
|
||||||
|
$('#formsavechangepassword').validate({
|
||||||
|
rules: {
|
||||||
|
currentPassword: {
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
newPassword: {
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
confirmNewPassword: {
|
||||||
|
required: true,
|
||||||
|
passwordMatch: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorPlacement: function(error, element) {
|
||||||
|
if (element.attr("name") === "newPassword" || element.attr("name") === "confirmNewPassword") {
|
||||||
|
$("#confirmPasswordError").text(error.text()).show();
|
||||||
|
} else {
|
||||||
|
error.insertAfter(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: function(label, element) {
|
||||||
|
if ($(element).attr("name") === "newPassword" || $(element).attr("name") === "confirmNewPassword") {
|
||||||
|
$("#confirmPasswordError").hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#formsavechangeusername').validate({
|
||||||
|
rules: {
|
||||||
|
newUsername: {
|
||||||
|
required: true,
|
||||||
|
usernamePattern: true
|
||||||
|
},
|
||||||
|
currentPasswordChangeUsername: {
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
newUsername: {
|
||||||
|
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errorPlacement: function(error, element) {
|
||||||
|
if (element.attr("name") === "newUsername") {
|
||||||
|
$("#usernameError").text(error.text()).show();
|
||||||
|
} else {
|
||||||
|
error.insertAfter(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: function(label, element) {
|
||||||
|
if ($(element).attr("name") === "newUsername") {
|
||||||
|
$("#usernameError").hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JavaScript for API Key -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
function copyToClipboard() {
|
||||||
|
const apiKeyElement = document.getElementById("apiKey");
|
||||||
|
apiKeyElement.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApiKey() {
|
||||||
|
const apiKeyElement = document.getElementById("apiKey");
|
||||||
|
const copyBtn = document.getElementById("copyBtn");
|
||||||
|
const eyeIcon = document.getElementById("eyeIcon");
|
||||||
|
if (apiKeyElement.type === "password") {
|
||||||
|
apiKeyElement.type = "text";
|
||||||
|
eyeIcon.textContent = "visibility_off";
|
||||||
|
copyBtn.disabled = false; // Enable copy button when API key is visible
|
||||||
|
} else {
|
||||||
|
apiKeyElement.type = "password";
|
||||||
|
eyeIcon.textContent = "visibility";
|
||||||
|
copyBtn.disabled = true; // Disable copy button when API key is hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async function() {
|
||||||
|
showApiKey();
|
||||||
|
try {
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const urlGetApiKey = /*[[@{/api/v1/user/get-api-key}]]*/ "/api/v1/user/get-api-key";
|
||||||
|
/*]]>*/
|
||||||
|
let response = await window.fetchWithCsrf(urlGetApiKey, { method: 'POST' });
|
||||||
|
if (response.status === 200) {
|
||||||
|
let apiKey = await response.text();
|
||||||
|
manageUIState(apiKey);
|
||||||
|
} else {
|
||||||
|
manageUIState(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshApiKey() {
|
||||||
|
try {
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const urlUpdateApiKey = /*[[@{/api/v1/user/update-api-key}]]*/ "/api/v1/user/update-api-key";
|
||||||
|
/*]]>*/
|
||||||
|
let response = await window.fetchWithCsrf(urlUpdateApiKey, { method: 'POST' });
|
||||||
|
if (response.status === 200) {
|
||||||
|
let apiKey = await response.text();
|
||||||
|
manageUIState(apiKey);
|
||||||
|
document.getElementById("apiKey").type = 'text';
|
||||||
|
document.getElementById("copyBtn").disabled = false;
|
||||||
|
} else {
|
||||||
|
alert('Error refreshing API key.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function manageUIState(apiKey) {
|
||||||
|
const apiKeyElement = document.getElementById("apiKey");
|
||||||
|
const showBtn = document.getElementById("showBtn");
|
||||||
|
const copyBtn = document.getElementById("copyBtn");
|
||||||
|
|
||||||
|
if (apiKey && apiKey.trim().length > 0) {
|
||||||
|
apiKeyElement.value = apiKey;
|
||||||
|
showBtn.disabled = false;
|
||||||
|
copyBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
apiKeyElement.value = "";
|
||||||
|
showBtn.disabled = true;
|
||||||
|
copyBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JavaScript for Settings Sync -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener("DOMContentLoaded", async function() {
|
||||||
|
const settingsTableBody = document.querySelector("#settingsTable tbody");
|
||||||
|
|
||||||
|
// Helper function to check if a key should be ignored
|
||||||
|
function shouldIgnoreKey(key) {
|
||||||
|
return key === 'debug' ||
|
||||||
|
key === '0' ||
|
||||||
|
key === '1' ||
|
||||||
|
key.includes('pdfjs') ||
|
||||||
|
key.includes('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>
|
</body>
|
||||||
</html>
|
</html>
|
@ -1,18 +1,19 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}"></th:block>
|
<th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}"></th:block>
|
||||||
|
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
|
||||||
<style>
|
<style>
|
||||||
.active-user {
|
.active-user {
|
||||||
color: green;
|
color: var(--md-sys-color-tertiary);
|
||||||
text-shadow: 0 0 5px green;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-overflow {
|
.text-overflow {
|
||||||
max-width: 100px;
|
max-width: 100px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow:ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -22,215 +23,354 @@
|
|||||||
<div id="page-container">
|
<div id="page-container">
|
||||||
<div id="content-wrap">
|
<div id="content-wrap">
|
||||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||||
<br><br>
|
|
||||||
<div class="container">
|
<div class="data-container">
|
||||||
<div class="row justify-content-center">
|
<div class="data-panel">
|
||||||
<div class="col-md-9 bg-card">
|
<div class="data-header">
|
||||||
<div class="tool-header">
|
<h1 class="data-title">
|
||||||
<span class="material-symbols-rounded tool-header-icon organize">manage_accounts</span>
|
<span class="data-icon">
|
||||||
<span class="tool-header-text" th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
|
<span class="material-symbols-rounded">manage_accounts</span>
|
||||||
</div>
|
</span>
|
||||||
|
<span th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
|
||||||
<!-- User Settings Title -->
|
</h1>
|
||||||
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
|
</div>
|
||||||
<a href="#"
|
|
||||||
th:data-bs-toggle="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : 'modal'"
|
<div class="data-body">
|
||||||
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
|
<!-- User Stats Banner -->
|
||||||
th:class="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
|
<div class="data-panel data-mb-3" style="background-color: var(--md-sys-color-primary-container);">
|
||||||
th:title="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
|
<div class="data-body" style="padding: 1.25rem;">
|
||||||
<span class="material-symbols-rounded">person_add</span>
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-around; align-items: center; gap: 1.5rem;">
|
||||||
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
</a>
|
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
|
||||||
|
group
|
||||||
<a href="#"
|
</span>
|
||||||
data-bs-toggle="modal"
|
<div>
|
||||||
data-bs-target="#changeUserRoleModal"
|
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.totalUsers}">Total Users</div>
|
||||||
class="btn btn-outline-success"
|
<div style="color: var(--md-sys-color-primary); font-size: 1.5rem; font-weight: 700;">
|
||||||
th:title="#{adminUserSettings.changeUserRole}">
|
<span th:text="${totalUsers}"></span>
|
||||||
<span class="material-symbols-rounded">edit</span>
|
<span th:if="${@runningProOrHigher}" th:text="'/' + ${maxPaidUsers}" style="font-size: 1rem;"></span>
|
||||||
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
|
</div>
|
||||||
<a th:href="@{'/usage'}" th:if="${@runningEE}"
|
|
||||||
class="btn btn-outline-success"
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
th:title="#{adminUserSettings.usage}">
|
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
|
||||||
<span class="material-symbols-rounded">analytics</span>
|
check_circle
|
||||||
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
|
</span>
|
||||||
</a>
|
<div>
|
||||||
|
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.activeUsers}">Active Users</div>
|
||||||
<div class="my-4">
|
<div style="color: var(--md-sys-color-primary); font-size: 1.5rem; font-weight: 700;" th:text="${activeUsers}"></div>
|
||||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
|
</div>
|
||||||
<span th:text="${totalUsers}"></span>
|
</div>
|
||||||
<span th:if="${@runningProOrHigher}" th:text="'/'+${maxPaidUsers}"></span>
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
|
<span class="material-symbols-rounded" style="font-size: 2.25rem; color: var(--md-sys-color-primary);">
|
||||||
<span th:text="${activeUsers}"></span>
|
person_off
|
||||||
|
</span>
|
||||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
|
<div>
|
||||||
<span th:text="${disabledUsers}"></span>
|
<div style="color: var(--md-sys-color-primary); font-size: 0.875rem; font-weight: 500;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users</div>
|
||||||
</div>
|
<div style="color: var(--md-sys-color-primary); font-size: 1.5rem; font-weight: 700;" th:text="${disabledUsers}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div th:if="${addMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
|
|
||||||
<div class="alert alert-danger mb-auto">
|
|
||||||
<span th:text="#{${addMessage}}">Default message if not found</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div th:if="${changeMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
|
|
||||||
<div class="alert alert-danger mb-auto">
|
<!-- Alert Messages -->
|
||||||
<span th:text="#{${changeMessage}}">Default message if not found</span>
|
<div th:if="${addMessage}" class="alert alert-danger data-mb-3">
|
||||||
</div>
|
<span th:text="#{${addMessage}}">Default message if not found</span>
|
||||||
</div>
|
</div>
|
||||||
<div th:if="${deleteMessage}" class="alert alert-danger">
|
|
||||||
|
<div th:if="${changeMessage}" class="alert alert-danger data-mb-3">
|
||||||
|
<span th:text="#{${changeMessage}}">Default message if not found</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div th:if="${deleteMessage}" class="alert alert-danger data-mb-3">
|
||||||
<span th:text="#{${deleteMessage}}">Default message if not found</span>
|
<span th:text="#{${deleteMessage}}">Default message if not found</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-card mt-3 mb-3 table-responsive">
|
|
||||||
<table class="table table-striped table-hover">
|
<!-- Admin Actions -->
|
||||||
|
<div class="data-section-title">User Management</div>
|
||||||
|
<div class="data-actions data-mb-3">
|
||||||
|
<button
|
||||||
|
th:data-bs-toggle="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : 'modal'"
|
||||||
|
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
|
||||||
|
th:class="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? 'data-btn data-btn-danger' : 'data-btn data-btn-primary'"
|
||||||
|
th:title="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
|
||||||
|
<span class="material-symbols-rounded">person_add</span>
|
||||||
|
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="/teams" class="data-btn data-btn-secondary" th:title="#{adminUserSettings.teams}">
|
||||||
|
<span class="material-symbols-rounded">group</span>
|
||||||
|
<span th:text="#{adminUserSettings.teams}">Manage Teams</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#changeUserRoleModal"
|
||||||
|
class="data-btn data-btn-secondary"
|
||||||
|
th:title="#{adminUserSettings.changeUserRole}">
|
||||||
|
<span class="material-symbols-rounded">edit</span>
|
||||||
|
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="/usage" th:if="${@runningEE}" class="data-btn data-btn-secondary" th:title="#{adminUserSettings.usage}">
|
||||||
|
<span class="material-symbols-rounded">analytics</span>
|
||||||
|
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">#</th>
|
<th scope="col">#</th>
|
||||||
<th scope="col" th:title="#{username}" th:text="#{username}">Username</th>
|
<th scope="col" th:title="#{username}" th:text="#{username}">Username</th>
|
||||||
|
<th scope="col" th:title="#{adminUserSettings.team}" th:text="#{adminUserSettings.team}">Team</th>
|
||||||
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">Roles</th>
|
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">Roles</th>
|
||||||
<th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow" th:text="#{adminUserSettings.authenticated}">Authenticated</th>
|
<th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow" th:text="#{adminUserSettings.authenticated}">Authenticated</th>
|
||||||
<th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
<th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
||||||
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}" colspan="2">Actions</th>
|
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}">Actions</th>
|
||||||
<!-- <th scope="col"></th> -->
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="user : ${users}">
|
<tr th:each="user : ${users}">
|
||||||
<th scope="row" style="align-content: center;" th:text="${user.id}"></th>
|
<td th:text="${user.id}"></td>
|
||||||
<td style="align-content: center;" th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
|
<td th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
|
||||||
<td style="align-content: center;" th:text="#{${user.roleName}}"></td>
|
<td th:text="${user.team != null ? user.team.name : '—'}"></td>
|
||||||
<td style="align-content: center;" th:text="${user.authenticationType}"></td>
|
<td>
|
||||||
<td style="align-content: center;" th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td>
|
<span class="data-badge" style="background-color: var(--md-sys-color-secondary-container); color: var(--md-sys-color-secondary); padding: 0.25rem 0.5rem; border-radius: 1rem; font-size: 0.875rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||||
<td style="align-content: center;">
|
<span class="material-symbols-rounded" style="font-size: 1rem;">shield</span>
|
||||||
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()">
|
<span th:text="#{${user.roleName}}">Role</span>
|
||||||
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="btn btn-info btn-sm"><span class="material-symbols-rounded">person_remove</span></button>
|
</span>
|
||||||
</form>
|
|
||||||
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}" th:href="@{'/account'}" class="btn btn-outline-success btn-sm"><span class="material-symbols-rounded">edit</span></a>
|
|
||||||
</td>
|
</td>
|
||||||
<td style="align-content: center;">
|
<td th:text="${user.authenticationType}"></td>
|
||||||
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post" onsubmit="return confirmChangeUserStatus()">
|
<td th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td>
|
||||||
<input type="hidden" name="enabled" th:value="!${user.enabled}" />
|
<td>
|
||||||
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}" class="btn btn-success btn-sm">
|
<div class="data-action-cell">
|
||||||
<span class="material-symbols-rounded">person</span>
|
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()" style="display: inline;">
|
||||||
</button>
|
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="data-icon-btn data-icon-btn-danger">
|
||||||
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}" class="btn btn-danger btn-sm">
|
<span class="material-symbols-rounded">person_remove</span>
|
||||||
<span class="material-symbols-rounded">person_off</span>
|
</button>
|
||||||
</button>
|
</form>
|
||||||
</form>
|
|
||||||
|
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}" th:href="@{'/account'}" class="data-icon-btn data-icon-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">edit</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post" onsubmit="return confirmChangeUserStatus()" style="display: inline;">
|
||||||
|
<input type="hidden" name="enabled" th:value="!${user.enabled}" />
|
||||||
|
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}" class="data-icon-btn data-icon-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">person</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}" class="data-icon-btn data-icon-btn-danger">
|
||||||
|
<span class="material-symbols-rounded">person_off</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p th:if="${!@runningProOrHigher}" th:text="#{enterpriseEdition.ssoAdvert}"></p>
|
|
||||||
|
<p th:if="${!@runningProOrHigher}" class="data-mt-3" th:text="#{enterpriseEdition.ssoAdvert}"></p>
|
||||||
<script th:inline="javascript">
|
|
||||||
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
|
|
||||||
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?';
|
|
||||||
function confirmDeleteUser() {
|
|
||||||
return confirm(delete_confirm_text);
|
|
||||||
}
|
|
||||||
function confirmChangeUserStatus() {
|
|
||||||
return confirm(change_confirm_text);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- change User role Modal start -->
|
<!-- Change User Role Modal -->
|
||||||
<div class="modal fade" id="changeUserRoleModal" tabindex="-1" aria-labelledby="changeUserRoleModalLabel" aria-hidden="true">
|
<div class="modal fade" id="changeUserRoleModal" tabindex="-1" aria-labelledby="changeUserRoleModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post" class="modal-content data-modal">
|
||||||
<div class="modal-header">
|
<div class="data-modal-header">
|
||||||
<h2 th:text="#{adminUserSettings.changeUserRole}">Change User's Role</h2>
|
<h5 class="data-modal-title">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
<span class="data-icon">
|
||||||
|
<span class="material-symbols-rounded">edit</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
<span class="material-symbols-rounded">close</span>
|
<span class="material-symbols-rounded">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="data-modal-body">
|
||||||
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{downgradeCurrentUserLongMessage}" th:text="#{help}">Help</button>
|
<div class="data-mb-2">
|
||||||
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post">
|
<button class="data-btn data-btn-secondary" data-toggle="tooltip" data-placement="auto" th:title="#{downgradeCurrentUserLongMessage}" style="padding: 0.25rem 0.5rem;">
|
||||||
<div class="mb-3">
|
<span class="material-symbols-rounded">help</span>
|
||||||
<label for="username" th:text="#{username}">Username</label>
|
<span th:text="#{help}">Help</span>
|
||||||
<select name="username" class="form-control" required>
|
</button>
|
||||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
</div>
|
||||||
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}" th:text="${user.username}">Username</option>
|
|
||||||
</select>
|
<div class="data-form-group">
|
||||||
</div>
|
<label for="username" class="data-form-label" th:text="#{username}">Username</label>
|
||||||
<div class="mb-3">
|
<select name="username" id="username" class="data-form-control" required>
|
||||||
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
|
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||||
<select name="role" class="form-control" required>
|
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}" th:text="${user.username}">Username</option>
|
||||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
</select>
|
||||||
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
<div class="data-form-group">
|
||||||
|
<label for="role" class="data-form-label" th:text="#{adminUserSettings.role}">Role</label>
|
||||||
<!-- Add other fields as required -->
|
<select name="role" id="role" class="data-form-control" required>
|
||||||
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
|
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||||
</form>
|
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="team" class="data-form-label" th:text="#{adminUserSettings.team}">Team</label>
|
||||||
|
<select name="teamId" id="team" class="data-form-control" required>
|
||||||
|
<option value="" th:text="#{selectFillter}">-- Select --</option>
|
||||||
|
<option th:each="team : ${teams}" th:value="${team.id}" th:text="${team.name}"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-form-actions">
|
||||||
|
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
<span th:text="#{cancel}">Cancel</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="data-btn data-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">check</span>
|
||||||
|
<span th:text="#{adminUserSettings.submit}">Save User</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer"></div>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- change User role Modal end -->
|
|
||||||
|
|
||||||
<!-- Add User Modal start -->
|
<!-- Add User Modal -->
|
||||||
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post" class="modal-content data-modal">
|
||||||
<div class="modal-header">
|
<div class="data-modal-header">
|
||||||
<h5 class="modal-title" id="addUserModalLabel" th:text="#{adminUserSettings.addUser}">Add New User</h5>
|
<h5 class="data-modal-title">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
<span class="data-icon">
|
||||||
|
<span class="material-symbols-rounded">person_add</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
<span class="material-symbols-rounded">close</span>
|
<span class="material-symbols-rounded">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="data-modal-body">
|
||||||
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{adminUserSettings.usernameInfo}" th:text="#{help}">Help</button>
|
<div class="data-mb-2">
|
||||||
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post">
|
<button class="data-btn data-btn-secondary" data-toggle="tooltip" data-placement="auto" th:title="#{adminUserSettings.usernameInfo}" style="padding: 0.25rem 0.5rem;">
|
||||||
<div class="mb-3">
|
<span class="material-symbols-rounded">help</span>
|
||||||
<label for="username" th:text="#{username}">Username</label>
|
<span th:text="#{help}">Help</span>
|
||||||
<input type="text" class="form-control" name="username" id="username" th:title="#{adminUserSettings.usernameInfo}" required>
|
</button>
|
||||||
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="mb-3" id="passwordContainer">
|
<div class="data-form-group">
|
||||||
<label for="password" th:text="#{password}">Password</label>
|
<label for="username" class="data-form-label" th:text="#{username}">Username</label>
|
||||||
<input type="password" class="form-control" name="password" id="password" required>
|
<input type="text" class="data-form-control" name="username" id="username" th:title="#{adminUserSettings.usernameInfo}" required>
|
||||||
</div>
|
<span id="usernameError" style="display: none; color: var(--md-sys-color-error);" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
||||||
<div class="mb-3">
|
</div>
|
||||||
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
|
|
||||||
<select name="role" class="form-control" id="role" required>
|
<div class="data-form-group" id="passwordContainer">
|
||||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
<label for="password" class="data-form-label" th:text="#{password}">Password</label>
|
||||||
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
<input type="password" class="data-form-control" name="password" id="password" required>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="data-form-group">
|
||||||
<label for="authType">Authentication Type</label>
|
<label for="role" class="data-form-label" th:text="#{adminUserSettings.role}">Role</label>
|
||||||
<select id="authType" name="authType" class="form-control" required>
|
<select name="role" class="data-form-control" id="role" required>
|
||||||
<option value="web" selected>WEB</option>
|
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||||
<option value="sso">SSO</option>
|
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check mb-3" id="checkboxContainer">
|
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="team" class="data-form-label" th:text="#{adminUserSettings.team}">Team</label>
|
||||||
|
<select name="teamId" class="data-form-control" required>
|
||||||
|
<option value="" th:text="#{selectFillter}">-- Select --</option>
|
||||||
|
<option th:each="team : ${teams}" th:value="${team.id}" th:text="${team.name}"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="authType" class="data-form-label">Authentication Type</label>
|
||||||
|
<select id="authType" name="authType" class="data-form-control" required>
|
||||||
|
<option value="web" selected>WEB</option>
|
||||||
|
<option value="sso">SSO</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-form-group" id="checkboxContainer">
|
||||||
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="forceChange" name="forceChange">
|
<input type="checkbox" class="form-check-input" id="forceChange" name="forceChange">
|
||||||
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user to change username/password on login</label>
|
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user to change username/password on login</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
|
</div>
|
||||||
</form>
|
|
||||||
|
<div class="data-form-actions">
|
||||||
|
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
<span th:text="#{cancel}">Cancel</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="data-btn data-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">check</span>
|
||||||
|
<span th:text="#{adminUserSettings.submit}">Save User</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer"></div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Team Modal -->
|
||||||
|
<div class="modal fade" id="addTeamModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<form th:action="@{'/api/v1/team/create'}" method="post" class="modal-content data-modal">
|
||||||
|
<div class="data-modal-header">
|
||||||
|
<h5 class="data-modal-title">
|
||||||
|
<span class="data-icon">
|
||||||
|
<span class="material-symbols-rounded">group_add</span>
|
||||||
|
</span>
|
||||||
|
<span th:text="#{adminUserSettings.createTeam}">Create Team</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="data-modal-body">
|
||||||
|
<div class="data-form-group">
|
||||||
|
<label for="teamName" class="data-form-label" th:text="#{adminUserSettings.teamName}">Team Name</label>
|
||||||
|
<input type="text" name="name" id="teamName" class="data-form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="data-form-actions">
|
||||||
|
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
<span th:text="#{cancel}">Cancel</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="data-btn data-btn-primary">
|
||||||
|
<span class="material-symbols-rounded">check</span>
|
||||||
|
<span th:text="#{adminUserSettings.submit}">Create</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add User Modal end -->
|
|
||||||
|
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
|
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
|
||||||
|
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?';
|
||||||
|
|
||||||
|
function confirmDeleteUser() {
|
||||||
|
return confirm(delete_confirm_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmChangeUserStatus() {
|
||||||
|
return confirm(change_confirm_text);
|
||||||
|
}
|
||||||
|
|
||||||
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
||||||
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
||||||
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
||||||
@ -241,6 +381,7 @@
|
|||||||
// Check if the field is optional or meets the requirements
|
// Check if the field is optional or meets the requirements
|
||||||
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
||||||
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$('[data-toggle="tooltip"]').tooltip();
|
$('[data-toggle="tooltip"]').tooltip();
|
||||||
|
|
||||||
@ -261,9 +402,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
username: {
|
username: {
|
||||||
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
errorPlacement: function(error, element) {
|
errorPlacement: function(error, element) {
|
||||||
if (element.attr("name") === "username") {
|
if (element.attr("name") === "username") {
|
||||||
@ -280,40 +421,40 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#username').on('input', function() {
|
$('#username').on('input', function() {
|
||||||
var usernameInput = $(this);
|
var usernameInput = $(this);
|
||||||
var isValid = usernameInput[0].checkValidity();
|
var isValid = usernameInput[0].checkValidity();
|
||||||
var errorSpan = $('#usernameError');
|
var errorSpan = $('#usernameError');
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
usernameInput.removeClass('invalid').addClass('valid');
|
usernameInput.removeClass('invalid').addClass('valid');
|
||||||
errorSpan.hide();
|
errorSpan.hide();
|
||||||
} else {
|
} else {
|
||||||
usernameInput.removeClass('valid').addClass('invalid');
|
usernameInput.removeClass('valid').addClass('invalid');
|
||||||
errorSpan.show();
|
errorSpan.show();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#authType').on('change', function() {
|
$('#authType').on('change', function() {
|
||||||
var authType = $(this).val();
|
var authType = $(this).val();
|
||||||
var passwordField = $('#password');
|
var passwordField = $('#password');
|
||||||
var passwordFieldContainer = $('#passwordContainer');
|
var passwordFieldContainer = $('#passwordContainer');
|
||||||
var checkboxContainer = $('#checkboxContainer');
|
var checkboxContainer = $('#checkboxContainer');
|
||||||
|
|
||||||
if (authType === 'sso') {
|
if (authType === 'sso') {
|
||||||
passwordField.removeAttr('required');
|
passwordField.removeAttr('required');
|
||||||
passwordField.prop('disabled', true).val('');
|
passwordField.prop('disabled', true).val('');
|
||||||
passwordFieldContainer.slideUp('fast');
|
passwordFieldContainer.slideUp('fast');
|
||||||
checkboxContainer.slideUp('fast');
|
checkboxContainer.slideUp('fast');
|
||||||
} else {
|
} else {
|
||||||
passwordField.prop('disabled', false);
|
passwordField.prop('disabled', false);
|
||||||
passwordField.attr('required', 'required');
|
passwordField.attr('required', 'required');
|
||||||
passwordFieldContainer.slideDown('fast');
|
passwordFieldContainer.slideDown('fast');
|
||||||
checkboxContainer.slideDown('fast');
|
checkboxContainer.slideDown('fast');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -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>
|
@ -32,6 +32,10 @@
|
|||||||
<label for="removeCertSign" th:text="#{merge.removeCertSign}">Remove digital signature in the merged
|
<label for="removeCertSign" th:text="#{merge.removeCertSign}">Remove digital signature in the merged
|
||||||
file?</label>
|
file?</label>
|
||||||
</div>
|
</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">
|
<div class="mb-3">
|
||||||
<ul id="selectedFiles" class="list-group"></ul>
|
<ul id="selectedFiles" class="list-group"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user