diff --git a/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/common/src/main/java/stirling/software/common/configuration/AppConfig.java index f0e5a6449..919bd0cd9 100644 --- a/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -255,7 +255,7 @@ public class AppConfig { @Bean(name = "disablePixel") public boolean disablePixel() { - return Boolean.getBoolean(env.getProperty("DISABLE_PIXEL")); + return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false")); } @Bean(name = "machineType") diff --git a/proprietary/src/main/java/stirling/software/proprietary/model/Team.java b/proprietary/src/main/java/stirling/software/proprietary/model/Team.java new file mode 100644 index 000000000..5157b3233 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/model/Team.java @@ -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 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); + } +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 6568ac3b0..5ae341a69 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.security; import java.sql.SQLException; +import java.util.List; import java.util.UUID; import org.springframework.stereotype.Component; @@ -13,7 +14,10 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.DatabaseServiceInterface; +import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; @Slf4j @@ -22,9 +26,8 @@ import stirling.software.proprietary.security.service.UserService; public class InitialSecuritySetup { private final UserService userService; - + private final TeamService teamService; private final ApplicationProperties applicationProperties; - private final DatabaseServiceInterface databaseService; @PostConstruct @@ -40,6 +43,7 @@ public class InitialSecuritySetup { } userService.migrateOauth2ToSSO(); + assignUsersToDefaultTeamIfMissing(); initializeInternalApiUser(); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.error("Failed to initialize security setup.", e); @@ -47,6 +51,19 @@ public class InitialSecuritySetup { } } + + private void assignUsersToDefaultTeamIfMissing() { + Team defaultTeam = teamService.getOrCreateDefaultTeam(); + List usersWithoutTeam = userService.getUsersWithoutTeam(); + + for (User user : usersWithoutTeam) { + user.setTeam(defaultTeam); + } + + userService.saveAll(usersWithoutTeam); // batch save + log.info("Assigned {} user(s) without a team to the default team.", usersWithoutTeam.size()); + } + private void initializeAdminUser() throws SQLException, UnsupportedProviderException { String initialUsername = applicationProperties.getSecurity().getInitialLogin().getUsername(); @@ -58,7 +75,9 @@ public class InitialSecuritySetup { && !initialPassword.isEmpty() && userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) { - userService.saveUser(initialUsername, initialPassword, Role.ADMIN.getRoleId()); + Team team = teamService.getOrCreateDefaultTeam(); + userService.saveUser( + initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false); log.info("Admin user created: {}", initialUsername); } else { createDefaultAdminUser(); @@ -70,7 +89,9 @@ public class InitialSecuritySetup { String defaultPassword = "stirling"; if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) { - userService.saveUser(defaultUsername, defaultPassword, Role.ADMIN.getRoleId(), true); + Team team = teamService.getOrCreateDefaultTeam(); + userService.saveUser( + defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true); log.info("Default admin user created: {}", defaultUsername); } } @@ -78,10 +99,13 @@ public class InitialSecuritySetup { private void initializeInternalApiUser() throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) { + Team team = teamService.getOrCreateInternalTeam(); userService.saveUser( Role.INTERNAL_API_USER.getRoleId(), UUID.randomUUID().toString(), - Role.INTERNAL_API_USER.getRoleId()); + team, + Role.INTERNAL_API_USER.getRoleId(), + false); userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId()); log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId()); } diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/AccountWebController.java b/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java similarity index 97% rename from proprietary/src/main/java/stirling/software/proprietary/security/controller/web/AccountWebController.java rename to proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index bdf1df32e..e80158f11 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/AccountWebController.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.security.controller.web; +package stirling.software.proprietary.security.config; import static stirling.software.common.util.ProviderUtils.validateProvider; @@ -38,10 +38,12 @@ import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.oauth2.GitHubProvider; import stirling.software.common.model.oauth2.GoogleProvider; import stirling.software.common.model.oauth2.KeycloakProvider; +import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.SessionEntity; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -57,16 +59,19 @@ public class AccountWebController { // Assuming you have a repository for user operations private final UserRepository userRepository; private final boolean runningEE; - + private final TeamRepository teamRepository; + public AccountWebController( ApplicationProperties applicationProperties, SessionPersistentRegistry sessionPersistentRegistry, UserRepository userRepository, + TeamRepository teamRepository, @Qualifier("runningEE") boolean runningEE) { this.applicationProperties = applicationProperties; this.sessionPersistentRegistry = sessionPersistentRegistry; this.userRepository = userRepository; this.runningEE = runningEE; + this.teamRepository=teamRepository; } @GetMapping("/login") @@ -210,7 +215,7 @@ public class AccountWebController { @GetMapping("/adminSettings") public String showAddUserForm( HttpServletRequest request, Model model, Authentication authentication) { - List allUsers = userRepository.findAll(); + List allUsers = userRepository.findAllWithTeam(); Iterator iterator = allUsers.iterator(); Map roleDetails = Role.getAllRoleDetails(); // Map to store session information and user activity status @@ -331,6 +336,9 @@ public class AccountWebController { model.addAttribute("activeUsers", activeUsers); model.addAttribute("disabledUsers", disabledUsers); + List allTeams = teamRepository.findAll(); + model.addAttribute("teams", allTeams); + model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers()); return "adminSettings"; } diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpoint.java b/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpoint.java new file mode 100644 index 000000000..02f398e12 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpoint.java @@ -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 {} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java b/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java new file mode 100644 index 000000000..4d9e3becb --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java @@ -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(); + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 2feab9a46..7964ccc5f 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -21,8 +21,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException; @Slf4j @Getter @Configuration -@EnableJpaRepositories(basePackages = "stirling.software.proprietary.security.database.repository") -@EntityScan({"stirling.software.proprietary.security.model"}) +@EnableJpaRepositories(basePackages = {"stirling.software.proprietary.security.database.repository", "stirling.software.proprietary.security.repository"}) +@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"}) public class DatabaseConfig { public final String DATASOURCE_DEFAULT_URL; diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java new file mode 100644 index 000000000..347c8b514 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -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 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 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"); + } +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index e1abb6989..608c96b0b 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -32,10 +32,13 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.api.user.UsernameAndPass; +import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -50,6 +53,7 @@ public class UserController { private final UserService userService; private final SessionPersistentRegistry sessionRegistry; private final ApplicationProperties applicationProperties; + private final TeamRepository teamRepository; @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") @@ -60,7 +64,13 @@ public class UserController { return "register"; } try { - userService.saveUser(requestModel.getUsername(), requestModel.getPassword()); + Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); + userService.saveUser( + requestModel.getUsername(), + requestModel.getPassword(), + team, + Role.USER.getRoleId(), + false); } catch (IllegalArgumentException e) { return "redirect:/login?messageType=invalidUsername"; } @@ -233,13 +243,14 @@ public class UserController { // If the role ID is not valid, redirect with an error message return new RedirectView("/adminSettings?messageType=invalidRole", true); } + Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) { - userService.saveUser(username, AuthenticationType.SSO, role); + userService.saveUser(username, AuthenticationType.SSO, team, role); } else { if (password.isBlank()) { return new RedirectView("/adminSettings?messageType=invalidPassword", true); } - userService.saveUser(username, password, role, forceChange); + userService.saveUser(username, password, team, role, forceChange); } return new RedirectView( "/adminSettings", // Redirect to account page after adding the user diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java b/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java new file mode 100644 index 000000000..5953f5d37 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java @@ -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 teams = teamRepository.findAllWithUsers(); + + // Get the latest activity for each team + List teamActivities = sessionRepository.findLatestActivityByTeam(); + + // Convert the query results to a map for easy access in the view + Map 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 members = userRepository.findAllByTeam(team); + team.setUsers(new HashSet<>(members)); + + // Get the latest session for each user in the team + List userSessions = sessionRepository.findLatestSessionByTeamId(id); + + // Create a map of username to last request date + Map 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"; + } +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java b/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java index 78206b259..3eb1ad90b 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java @@ -29,4 +29,20 @@ public interface SessionRepository extends JpaRepository @Param("expired") boolean expired, @Param("lastRequest") Date lastRequest, @Param("principalName") String principalName); + + @Query( + "SELECT t.id as teamId, MAX(s.lastRequest) as lastActivity " + + "FROM stirling.software.proprietary.model.Team t " + + "LEFT JOIN t.users u " + + "LEFT JOIN SessionEntity s ON u.username = s.principalName " + + "GROUP BY t.id") + List 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 findLatestSessionByTeamId(@Param("teamId") Long teamId); } diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 2a8d42096..98d4d510c 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; @Repository @@ -22,4 +23,14 @@ public interface UserRepository extends JpaRepository { Optional findByApiKey(String apiKey); List findByAuthenticationTypeIgnoreCase(String authenticationType); + + @Query("SELECT u FROM User u WHERE u.team IS NULL") + List findAllWithoutTeam(); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.team") + List findAllWithTeam(); + + long countByTeam(Team team); + + List findAllByTeam(Team team); } diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index b364f3738..c2461269a 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -16,6 +16,7 @@ import lombok.Setter; import lombok.ToString; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.model.Team; @Entity @Table(name = "users") @@ -57,6 +58,10 @@ public class User implements Serializable { @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") private Set authorities = new HashSet<>(); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id") + private Team team; + @ElementCollection @MapKeyColumn(name = "setting_key") @Lob diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java b/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java new file mode 100644 index 000000000..e3f72d533 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java @@ -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 { + Optional findByName(String name); + + @Query("SELECT t FROM Team t LEFT JOIN FETCH t.users") + List findAllWithUsers(); + + boolean existsByNameIgnoreCase(String name); +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java b/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java new file mode 100644 index 000000000..194a2a967 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java @@ -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); + }); + } +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 0823f748b..9d1ae6fc8 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Supplier; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; @@ -31,11 +32,13 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.AuthorityRepository; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -45,7 +48,7 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry; public class UserService implements UserServiceInterface { private final UserRepository userRepository; - + private final TeamRepository teamRepository; private final AuthorityRepository authorityRepository; private final PasswordEncoder passwordEncoder; @@ -162,7 +165,7 @@ public class UserService implements UserServiceInterface { public void saveUser(String username, AuthenticationType authenticationType) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - saveUser(username, authenticationType, Role.USER.getRoleId()); + saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId()); } private User saveUser(Optional user, String apiKey) { @@ -173,71 +176,98 @@ public class UserService implements UserServiceInterface { throw new UsernameNotFoundException("User not found"); } - public void saveUser(String username, AuthenticationType authenticationType, String role) + public User saveUser( + String username, AuthenticationType authenticationType, Long teamId, String role) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - if (!isUsernameValid(username)) { - throw new IllegalArgumentException(getInvalidUsernameMessage()); - } - User user = new User(); - user.setUsername(username); - user.setEnabled(true); - user.setFirstLogin(false); - user.addAuthority(new Authority(role, user)); - user.setAuthenticationType(authenticationType); - userRepository.save(user); - databaseService.exportDatabase(); + return saveUserCore( + username, // username + null, // password + authenticationType, // authenticationType + teamId, // teamId + null, // team + role, // role + false, // firstLogin + true // enabled + ); } - public void saveUser(String username, String password) + public User saveUser( + String username, AuthenticationType authenticationType, Team team, String role) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - if (!isUsernameValid(username)) { - throw new IllegalArgumentException(getInvalidUsernameMessage()); - } - User user = new User(); - user.setUsername(username); - user.setPassword(passwordEncoder.encode(password)); - user.setEnabled(true); - user.setAuthenticationType(AuthenticationType.WEB); - user.addAuthority(new Authority(Role.USER.getRoleId(), user)); - userRepository.save(user); - databaseService.exportDatabase(); + return saveUserCore( + username, // username + null, // password + authenticationType, // authenticationType + null, // teamId + team, // team + role, // role + false, // firstLogin + true // enabled + ); } - public void saveUser(String username, String password, String role, boolean firstLogin) + public User saveUser(String username, String password, Long teamId) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - if (!isUsernameValid(username)) { - throw new IllegalArgumentException(getInvalidUsernameMessage()); - } - User user = new User(); - user.setUsername(username); - user.setPassword(passwordEncoder.encode(password)); - user.addAuthority(new Authority(role, user)); - user.setEnabled(true); - user.setAuthenticationType(AuthenticationType.WEB); - user.setFirstLogin(firstLogin); - userRepository.save(user); - databaseService.exportDatabase(); + return saveUserCore( + username, // username + password, // password + AuthenticationType.WEB, // authenticationType + teamId, // teamId + null, // team + Role.USER.getRoleId(), // role + false, // firstLogin + true // enabled + ); } - public void saveUser(String username, String password, String role) + public User saveUser( + String username, String password, Team team, String role, boolean firstLogin) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - saveUser(username, password, role, false); + return saveUserCore( + username, // username + password, // password + AuthenticationType.WEB, // authenticationType + null, // teamId + team, // team + role, // role + firstLogin, // firstLogin + true // enabled + ); } - public void saveUser(String username, String password, boolean firstLogin, boolean enabled) + public User saveUser( + String username, String password, Long teamId, String role, boolean firstLogin) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - if (!isUsernameValid(username)) { - throw new IllegalArgumentException(getInvalidUsernameMessage()); - } - User user = new User(); - user.setUsername(username); - user.setPassword(passwordEncoder.encode(password)); - user.addAuthority(new Authority(Role.USER.getRoleId(), user)); - user.setEnabled(enabled); - user.setAuthenticationType(AuthenticationType.WEB); - user.setFirstLogin(firstLogin); - userRepository.save(user); - databaseService.exportDatabase(); + return saveUserCore( + username, // username + password, // password + AuthenticationType.WEB, // authenticationType + teamId, // teamId + null, // team + role, // role + firstLogin, // firstLogin + true // enabled + ); + } + + public void saveUser(String username, String password, Long teamId, String role) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + saveUser(username, password, teamId, role, false); + } + + public void saveUser( + String username, String password, Long teamId, boolean firstLogin, boolean enabled) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + saveUserCore( + username, // username + password, // password + AuthenticationType.WEB, // authenticationType + teamId, // teamId + null, // team + Role.USER.getRoleId(), // role + firstLogin, // firstLogin + enabled // enabled + ); } public void deleteUser(String username) { @@ -345,6 +375,111 @@ public class UserService implements UserServiceInterface { return passwordEncoder.matches(currentPassword, user.getPassword()); } + /** + * Resolves a team based on the provided information, with consistent error handling. + * + * @param teamId The ID of the team to find, may be null + * @param defaultTeamSupplier A supplier that provides a default team when teamId is null + * @return The resolved Team object + * @throws IllegalArgumentException If the teamId is invalid + */ + private Team resolveTeam(Long teamId, Supplier defaultTeamSupplier) { + if (teamId == null) { + return defaultTeamSupplier.get(); + } + + return teamRepository + .findById(teamId) + .orElseThrow(() -> new IllegalArgumentException("Invalid team ID: " + teamId)); + } + + /** + * Gets the default team, creating it if it doesn't exist. + * + * @return The default team + */ + private Team getDefaultTeam() { + return teamRepository + .findByName("Default") + .orElseGet( + () -> { + Team team = new Team(); + team.setName("Default"); + return teamRepository.save(team); + }); + } + + /** + * Core implementation for saving a user with all possible parameters. This method centralizes + * the common logic for all saveUser variants. + * + * @param username Username for the new user + * @param password Password for the user (may be null for SSO/OAuth users) + * @param authenticationType Type of authentication (WEB, SSO, etc.) + * @param teamId ID of the team to assign (may be null to use default) + * @param team Team object to assign (takes precedence over teamId if both provided) + * @param role Role to assign to the user + * @param firstLogin Whether this is the user's first login + * @param enabled Whether the user account is enabled + * @return The saved User object + * @throws IllegalArgumentException If username is invalid or team is invalid + * @throws SQLException If database operation fails + * @throws UnsupportedProviderException If provider is not supported + */ + private User saveUserCore( + String username, + String password, + AuthenticationType authenticationType, + Long teamId, + Team team, + String role, + boolean firstLogin, + boolean enabled) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + + if (!isUsernameValid(username)) { + throw new IllegalArgumentException(getInvalidUsernameMessage()); + } + + User user = new User(); + user.setUsername(username); + + // Set password if provided + if (password != null && !password.isEmpty()) { + user.setPassword(passwordEncoder.encode(password)); + } + + // Set authentication type + user.setAuthenticationType(authenticationType); + + // Set enabled status + user.setEnabled(enabled); + + // Set first login flag + user.setFirstLogin(firstLogin); + + // Set role (authority) + if (role == null) { + role = Role.USER.getRoleId(); + } + user.addAuthority(new Authority(role, user)); + + // Resolve and set team + if (team != null) { + user.setTeam(team); + } else { + user.setTeam(resolveTeam(teamId, this::getDefaultTeam)); + } + + // Save user + userRepository.save(user); + + // Export database + databaseService.exportDatabase(); + + return user; + } + public boolean isUsernameValid(String username) { // Checks whether the simple username is formatted correctly // Regular expression for user name: Min. 3 characters, max. 50 characters @@ -464,7 +599,6 @@ public class UserService implements UserServiceInterface { } } - @Override public long getTotalUsersCount() { // Count all users in the database long userCount = userRepository.count(); @@ -474,4 +608,12 @@ public class UserService implements UserServiceInterface { } return userCount; } + + public List getUsersWithoutTeam() { + return userRepository.findAllWithoutTeam(); + } + + public void saveAll(List users) { + userRepository.saveAll(users); + } } diff --git a/proprietary/src/main/resources/static/css/modern-tables.css b/proprietary/src/main/resources/static/css/modern-tables.css new file mode 100644 index 000000000..8acd8360a --- /dev/null +++ b/proprietary/src/main/resources/static/css/modern-tables.css @@ -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 + } \ No newline at end of file diff --git a/proprietary/src/main/resources/templates/enterprise/team-details.html b/proprietary/src/main/resources/templates/enterprise/team-details.html new file mode 100644 index 000000000..e41507a69 --- /dev/null +++ b/proprietary/src/main/resources/templates/enterprise/team-details.html @@ -0,0 +1,95 @@ + + + + + + + + + +
+
+ + +
+
+
+

+ + group + + Team Name +

+
+ +
+
+
+
Team ID:
+
1
+
+
+
Total Members:
+
1
+
+
+ + + +
Members
+ +
+ + + + + + + + + + + + + + + + + + + +
IDUsernameRoleLast RequestStatus
1usernameRole2023-01-01 12:00:00 + + person + Enabled + + + person_off + Disabled + +
+
+ + +
+ person_off +

This team has no members yet.

+ + person_add + Add Users to Team + +
+
+
+
+
+ + +
+ + \ No newline at end of file diff --git a/proprietary/src/main/resources/templates/enterprise/teams.html b/proprietary/src/main/resources/templates/enterprise/teams.html new file mode 100644 index 000000000..34d3e7f1c --- /dev/null +++ b/proprietary/src/main/resources/templates/enterprise/teams.html @@ -0,0 +1,122 @@ + + + + + + + + + +
+
+ + +
+
+
+

+ + groups + + Team Management +

+
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + +
Team NameTotal MembersLast RequestActions
+
+ + search View + +
+ + +
+
+
+
+ + + +
+
+
+ + + +
+ +
+ + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 9f7584d3a..319ea8251 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,6 @@ plugins { // Apply the foojay-resolver plugin to allow automatic download of JDKs id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' } -rootProject.name = 'Stirling-PDF' +rootProject.name = 'Stirling PDF' include 'stirling-pdf', 'common', 'proprietary' diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java new file mode 100644 index 000000000..b5fea0642 --- /dev/null +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java @@ -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> 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> extractBookmarkItems(PDDocument document, PDDocumentOutline outline) throws Exception { + List> bookmarks = new ArrayList<>(); + PDOutlineItem current = outline.getFirstChild(); + + while (current != null) { + Map 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> children = new ArrayList<>(); + PDOutlineNode parent = current; + + while (child != null) { + // Recursively process child items + Map 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 processChild(PDDocument document, PDOutlineItem item) throws Exception { + Map 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> children = new ArrayList<>(); + + while (child != null) { + // Recursively process child items + Map 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 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 bookmarks = objectMapper.readValue( + request.getBookmarkData(), + new TypeReference>() {}); + + // 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 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 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 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 getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } + } +} \ No newline at end of file diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 146db6a3a..2d800cf43 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -15,6 +15,8 @@ import org.apache.pdfbox.multipdf.PDFMergerUtility; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.apache.pdfbox.pdmodel.interactive.form.PDField; import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; @@ -110,6 +112,46 @@ public class MergeController { } } + // Adds a table of contents to the merged document using filenames as chapter titles + private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) { + // Create the document outline + PDDocumentOutline outline = new PDDocumentOutline(); + mergedDocument.getDocumentCatalog().setDocumentOutline(outline); + + int pageIndex = 0; // Current page index in the merged document + + // Iterate through the original files + for (MultipartFile file : files) { + // Get the filename without extension to use as bookmark title + String filename = file.getOriginalFilename(); + String title = filename; + if (title != null && title.contains(".")) { + title = title.substring(0, title.lastIndexOf('.')); + } + + // Create an outline item for this file + PDOutlineItem item = new PDOutlineItem(); + item.setTitle(title); + + // Set the destination to the first page of this file in the merged document + if (pageIndex < mergedDocument.getNumberOfPages()) { + PDPage page = mergedDocument.getPage(pageIndex); + item.setDestination(page); + } + + // Add the item to the outline + outline.addLast(item); + + // Increment page index for the next file + try (PDDocument doc = pdfDocumentFactory.load(file)) { + pageIndex += doc.getNumberOfPages(); + } catch (IOException e) { + log.error("Error loading document for TOC generation", e); + pageIndex++; // Increment by at least one if we can't determine page count + } + } + } + @PostMapping(consumes = "multipart/form-data", value = "/merge-pdfs") @Operation( summary = "Merge multiple PDF files into one", @@ -124,6 +166,7 @@ public class MergeController { PDDocument mergedDocument = null; boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign()); + boolean generateToc = request.isGenerateToc(); try { MultipartFile[] files = request.getFileInput(); @@ -169,6 +212,11 @@ public class MergeController { } } } + + // Add table of contents if generateToc is true + if (generateToc && files.length > 0) { + addTableOfContents(mergedDocument, files); + } // Save the modified document to a new ByteArrayOutputStream ByteArrayOutputStream baos = new ByteArrayOutputStream(); diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/EditTableOfContentsRequest.java b/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/EditTableOfContentsRequest.java new file mode 100644 index 000000000..4fc6c7e97 --- /dev/null +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/EditTableOfContentsRequest.java @@ -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; +} \ No newline at end of file diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java b/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java index 0ed4fcfa7..4f1712a0e 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java @@ -32,4 +32,11 @@ public class MergePdfsRequest extends MultiplePDFFiles { requiredMode = Schema.RequiredMode.REQUIRED, defaultValue = "true") private Boolean removeCertSign; + + @Schema( + description = + "Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names.", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "false") + private boolean generateToc = false; } diff --git a/stirling-pdf/src/main/resources/messages_en_GB.properties b/stirling-pdf/src/main/resources/messages_en_GB.properties index 56b1559c0..95c12be05 100644 --- a/stirling-pdf/src/main/resources/messages_en_GB.properties +++ b/stirling-pdf/src/main/resources/messages_en_GB.properties @@ -127,6 +127,7 @@ enterpriseEdition.button=Upgrade to Pro enterpriseEdition.warning=This feature is only available to Pro users. enterpriseEdition.yamlAdvert=Stirling PDF Pro supports YAML configuration files and other SSO features. enterpriseEdition.ssoAdvert=Looking for more user management features? Check out Stirling PDF Pro +enterpriseEdition.proTeamFeatureDisabled=Team management features require a Pro licence or higher ################# @@ -239,6 +240,24 @@ adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request adminUserSettings.usage=View Usage +adminUserSettings.teams=View/Edit Teams +adminUserSettings.team=Team +adminUserSettings.manageTeams=Manage Teams +adminUserSettings.createTeam=Create Team +adminUserSettings.teamName=Team Name +adminUserSettings.teamExists=Team already exists +adminUserSettings.teamCreated=Team created successfully +adminUserSettings.teamChanged=User's team was updated +adminUserSettings.totalMembers=Total Members + +teamCreated=Team created successfully +teamExists=A team with that name already exists +teamNameExists=Another team with that name already exists +teamNotFound=Team not found +teamDeleted=Team deleted +teamHasUsers=Cannot delete a team with users assigned +teamRenamed=Team renamed successfully + endpointStatistics.title=Endpoint Statistics endpointStatistics.header=Endpoint Statistics endpointStatistics.top10=Top 10 @@ -1010,6 +1029,7 @@ merge.header=Merge multiple PDFs (2+) merge.sortByName=Sort by name merge.sortByDate=Sort by date merge.removeCertSign=Remove digital signature in the merged file? +merge.generateToc=Generate table of contents in the merged file? merge.submit=Merge @@ -1459,3 +1479,19 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential cookieBanner.preferencesModal.analytics.title=Analytics cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with. + +# Table of Contents Feature +home.editTableOfContents.title=Edit Table of Contents +home.editTableOfContents.desc=Add or edit bookmarks and table of contents in PDF documents +editTableOfContents.tags=bookmarks,toc,navigation,index,table of contents,chapters,sections,outline +editTableOfContents.title=Edit Table of Contents +editTableOfContents.header=Add or Edit PDF Table of Contents +editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to append to existing) +editTableOfContents.editorTitle=Bookmark Editor +editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks. +editTableOfContents.addBookmark=Add New Bookmark +editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document. +editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks. +editTableOfContents.desc.3=Each bookmark requires a title and target page number. +editTableOfContents.submit=Apply Table of Contents + diff --git a/stirling-pdf/src/main/resources/messages_en_US.properties b/stirling-pdf/src/main/resources/messages_en_US.properties index 1b21ed391..91947d10b 100644 --- a/stirling-pdf/src/main/resources/messages_en_US.properties +++ b/stirling-pdf/src/main/resources/messages_en_US.properties @@ -127,6 +127,7 @@ enterpriseEdition.button=Upgrade to Pro enterpriseEdition.warning=This feature is only available to Pro users. enterpriseEdition.yamlAdvert=Stirling PDF Pro supports YAML configuration files and other SSO features. enterpriseEdition.ssoAdvert=Looking for more user management features? Check out Stirling PDF Pro +enterpriseEdition.proTeamFeatureDisabled=Team management features require a Pro license or higher ################# @@ -1010,6 +1011,7 @@ merge.header=Merge multiple PDFs (2+) merge.sortByName=Sort by name merge.sortByDate=Sort by date merge.removeCertSign=Remove digital signature in the merged file? +merge.generateToc=Generate table of contents in the merged file? merge.submit=Merge diff --git a/stirling-pdf/src/main/resources/static/css/edit-table-of-contents.css b/stirling-pdf/src/main/resources/static/css/edit-table-of-contents.css new file mode 100644 index 000000000..11a4bf777 --- /dev/null +++ b/stirling-pdf/src/main/resources/static/css/edit-table-of-contents.css @@ -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); +} \ No newline at end of file diff --git a/stirling-pdf/src/main/resources/templates/account.html b/stirling-pdf/src/main/resources/templates/account.html index 662a4f72c..c82739924 100644 --- a/stirling-pdf/src/main/resources/templates/account.html +++ b/stirling-pdf/src/main/resources/templates/account.html @@ -1,7 +1,8 @@ - + + @@ -9,353 +10,462 @@
-

-
-
-
-
- settings_account_box - User Settings -
- - - -
+ +
+
+
+

+ + settings_account_box + + User Settings +

+
+ +
+
Default message if not found
- - - -

User!

- -