diff --git a/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java b/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java index 9339405da..f4d4da18c 100644 --- a/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java +++ b/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.config.security; import java.sql.SQLException; +import java.util.List; import java.util.UUID; import org.springframework.stereotype.Component; @@ -13,6 +14,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.interfaces.DatabaseInterface; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.Role; +import stirling.software.SPDF.model.Team; +import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.exception.UnsupportedProviderException; @Slf4j @@ -21,7 +24,7 @@ import stirling.software.SPDF.model.exception.UnsupportedProviderException; public class InitialSecuritySetup { private final UserService userService; - +private final TeamService teamService; private final ApplicationProperties applicationProperties; private final DatabaseInterface databaseService; @@ -39,12 +42,26 @@ public class InitialSecuritySetup { } userService.migrateOauth2ToSSO(); + assignUsersToDefaultTeamIfMissing(); initializeInternalApiUser(); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.error("Failed to initialize security setup.", e); System.exit(1); } } + + 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 = @@ -56,8 +73,8 @@ public class InitialSecuritySetup { && initialPassword != null && !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(); @@ -69,7 +86,8 @@ 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); } } @@ -77,10 +95,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/src/main/java/stirling/software/SPDF/config/security/TeamService.java b/src/main/java/stirling/software/SPDF/config/security/TeamService.java new file mode 100644 index 000000000..3eb151bbe --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/TeamService.java @@ -0,0 +1,34 @@ +package stirling.software.SPDF.config.security; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import stirling.software.SPDF.model.Team; +import stirling.software.SPDF.repository.TeamRepository; + +@Service +@RequiredArgsConstructor +public class TeamService { + + private final TeamRepository teamRepository; + + public static final String DEFAULT_TEAM_NAME = "Default"; + public static final String INTERNAL_TEAM_NAME = "Internal"; + public Team getOrCreateDefaultTeam() { + return teamRepository.findByName(DEFAULT_TEAM_NAME) + .orElseGet(() -> { + Team defaultTeam = new Team(); + defaultTeam.setName(DEFAULT_TEAM_NAME); + return teamRepository.save(defaultTeam); + }); + } + public Team getOrCreateInternalTeam() { + return teamRepository.findByName(INTERNAL_TEAM_NAME) + .orElseGet(() -> { + Team internalTeam = new Team(); + internalTeam.setName(INTERNAL_TEAM_NAME); + return teamRepository.save(internalTeam); + }); + } + +} diff --git a/src/main/java/stirling/software/SPDF/config/security/UserService.java b/src/main/java/stirling/software/SPDF/config/security/UserService.java index d90539171..ef389b7b0 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserService.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserService.java @@ -29,6 +29,7 @@ import stirling.software.SPDF.controller.api.pipeline.UserServiceInterface; import stirling.software.SPDF.model.*; import stirling.software.SPDF.model.exception.UnsupportedProviderException; import stirling.software.SPDF.repository.AuthorityRepository; +import stirling.software.SPDF.repository.TeamRepository; import stirling.software.SPDF.repository.UserRepository; @Service @@ -37,7 +38,7 @@ import stirling.software.SPDF.repository.UserRepository; public class UserService implements UserServiceInterface { private final UserRepository userRepository; - + private final TeamRepository teamRepository; private final AuthorityRepository authorityRepository; private final PasswordEncoder passwordEncoder; @@ -152,10 +153,41 @@ public class UserService implements UserServiceInterface { return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey()); } - public void saveUser(String username, AuthenticationType authenticationType) + public void saveUser(String username, AuthenticationType authenticationType,Long teamId) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - saveUser(username, authenticationType, Role.USER.getRoleId()); + saveUser(username, authenticationType, teamId, Role.USER.getRoleId()); } + + public User saveUser(String username, AuthenticationType type) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + + if (!isUsernameValid(username)) { + throw new IllegalArgumentException(getInvalidUsernameMessage()); + } + + User user = new User(); + user.setUsername(username); + user.setAuthenticationType(type); + user.setEnabled(true); + user.setFirstLogin(true); + + String defaultRole = Role.USER.getRoleId(); + user.addAuthority(new Authority(defaultRole, user)); + + Team defaultTeam = teamRepository.findByName("Default") + .orElseGet(() -> { + Team team = new Team(); + team.setName("Default"); + return teamRepository.save(team); + }); + user.setTeam(defaultTeam); + userRepository.save(user); + databaseService.exportDatabase(); + + return user; + } + + private User saveUser(Optional user, String apiKey) { if (user.isPresent()) { @@ -165,7 +197,11 @@ public class UserService implements UserServiceInterface { throw new UsernameNotFoundException("User not found"); } - public void saveUser(String username, AuthenticationType authenticationType, String role) + public User saveUser(User user) { + return userRepository.save(user); + } + + public User saveUser(String username, AuthenticationType authenticationType,Team team, String role) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { throw new IllegalArgumentException(getInvalidUsernameMessage()); @@ -175,12 +211,66 @@ public class UserService implements UserServiceInterface { user.setEnabled(true); user.setFirstLogin(false); user.addAuthority(new Authority(role, user)); + user.setTeam(team); user.setAuthenticationType(authenticationType); userRepository.save(user); databaseService.exportDatabase(); + return user; + } + + public User saveUser(String username, AuthenticationType authenticationType,Long teamId, String role) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + if (!isUsernameValid(username)) { + throw new IllegalArgumentException(getInvalidUsernameMessage()); + } + User user = new User(); + user.setUsername(username); + user.setEnabled(true); + user.setFirstLogin(false); + user.addAuthority(new Authority(role, user)); + Optional optTeam = teamRepository.findById(teamId); + if(!optTeam.isPresent()) { + throw new IllegalArgumentException("Team ID was invalid, team not present"); + } + user.setTeam(optTeam.get()); + user.setAuthenticationType(authenticationType); + userRepository.save(user); + databaseService.exportDatabase(); + return user; } - public void saveUser(String username, String password) + public User saveUser(String username, String password, Long teamId) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + + if (!isUsernameValid(username)) { + throw new IllegalArgumentException(getInvalidUsernameMessage()); + } + + // Fetch team or throw + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new IllegalArgumentException("Invalid team ID: " + teamId)); + + // Create user + User user = new User(); + user.setUsername(username); + user.setPassword(passwordEncoder.encode(password)); + user.setEnabled(true); + user.setAuthenticationType(AuthenticationType.WEB); + user.setTeam(team); + user.setFirstLogin(false); // or true depending on your policy + + // Assign default USER role + user.addAuthority(new Authority(Role.USER.getRoleId(), user)); + + // Save user + userRepository.save(user); + databaseService.exportDatabase(); + + return user; + } + + + public User saveUser(String username, String password, Team team, String role, boolean firstLogin) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { throw new IllegalArgumentException(getInvalidUsernameMessage()); @@ -188,14 +278,17 @@ public class UserService implements UserServiceInterface { User user = new User(); user.setUsername(username); user.setPassword(passwordEncoder.encode(password)); + user.addAuthority(new Authority(role, user)); user.setEnabled(true); + user.setTeam(team); user.setAuthenticationType(AuthenticationType.WEB); - user.addAuthority(new Authority(Role.USER.getRoleId(), user)); + user.setFirstLogin(firstLogin); userRepository.save(user); databaseService.exportDatabase(); + return user; } - - public void saveUser(String username, String password, String role, boolean firstLogin) + + public User saveUser(String username, String password, Long teamId, String role, boolean firstLogin) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { throw new IllegalArgumentException(getInvalidUsernameMessage()); @@ -209,14 +302,15 @@ public class UserService implements UserServiceInterface { user.setFirstLogin(firstLogin); userRepository.save(user); databaseService.exportDatabase(); + return user; } - public void saveUser(String username, String password, String role) + public void saveUser(String username, String password, Long teamId, String role) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - saveUser(username, password, role, false); + saveUser(username, password, teamId , role, false); } - public void saveUser(String username, String password, boolean firstLogin, boolean enabled) + public void saveUser(String username, String password,Long teamId, boolean firstLogin, boolean enabled) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { throw new IllegalArgumentException(getInvalidUsernameMessage()); @@ -336,6 +430,10 @@ public class UserService implements UserServiceInterface { databaseService.exportDatabase(); } + public void saveAll(List users) { + userRepository.saveAll(users); + } + public boolean isPasswordCorrect(User user, String currentPassword) { return passwordEncoder.matches(currentPassword, user.getPassword()); } @@ -460,7 +558,6 @@ public class UserService implements UserServiceInterface { } } - @Override public long getTotalUsersCount() { // Count all users in the database long userCount = userRepository.count(); @@ -470,4 +567,10 @@ public class UserService implements UserServiceInterface { } return userCount; } + public List getUsersWithoutTeam() { + return userRepository.findAllWithoutTeam(); + } + + + } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java index b7f0133f3..68509637f 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java @@ -29,4 +29,21 @@ 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 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 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/src/main/java/stirling/software/SPDF/controller/api/TeamController.java b/src/main/java/stirling/software/SPDF/controller/api/TeamController.java new file mode 100644 index 000000000..90cda522b --- /dev/null +++ b/src/main/java/stirling/software/SPDF/controller/api/TeamController.java @@ -0,0 +1,79 @@ +package stirling.software.SPDF.controller.api; + +import java.util.List; +import java.util.Optional; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.view.RedirectView; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.Team; +import stirling.software.SPDF.repository.TeamRepository; +import stirling.software.SPDF.repository.UserRepository; + +@Controller +@RequestMapping("/api/v1/team") +@Tag(name = "Team", description = "Team Management APIs") +@Slf4j +@RequiredArgsConstructor +public class TeamController { + + private final TeamRepository teamRepository; + private final UserRepository userRepository; + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/create") + public RedirectView createTeam(@RequestParam("name") String name) { + if (teamRepository.existsByNameIgnoreCase(name)) { + return new RedirectView("/adminSettings?messageType=teamExists"); + } + Team team = new Team(); + team.setName(name); + teamRepository.save(team); + return new RedirectView("/adminSettings?messageType=teamCreated"); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/rename") + public RedirectView renameTeam(@RequestParam("teamId") Long teamId, + @RequestParam("newName") String newName) { + Optional 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/src/main/java/stirling/software/SPDF/controller/api/TeamWebController.java b/src/main/java/stirling/software/SPDF/controller/api/TeamWebController.java new file mode 100644 index 000000000..c4d0d0412 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/controller/api/TeamWebController.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.controller.api; + +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import lombok.RequiredArgsConstructor; +import stirling.software.SPDF.config.security.session.SessionRepository; +import stirling.software.SPDF.model.Team; +import stirling.software.SPDF.model.User; +import stirling.software.SPDF.repository.TeamRepository; +import stirling.software.SPDF.repository.UserRepository; + +@Controller +@RequestMapping("/teams") +@RequiredArgsConstructor +public class TeamWebController { + + private final TeamRepository teamRepository; + private final UserRepository userRepository; + private final SessionRepository sessionRepository; + + @GetMapping + @PreAuthorize("hasRole('ROLE_ADMIN')") + public String listTeams(Model model) { + // Get all teams with their users + List 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 "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 "team-details"; + } +} diff --git a/src/main/java/stirling/software/SPDF/controller/api/UserController.java b/src/main/java/stirling/software/SPDF/controller/api/UserController.java index ce4770499..556c44f61 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/UserController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/UserController.java @@ -36,9 +36,11 @@ import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.AuthenticationType; import stirling.software.SPDF.model.Role; +import stirling.software.SPDF.model.Team; import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.api.user.UsernameAndPass; import stirling.software.SPDF.model.exception.UnsupportedProviderException; +import stirling.software.SPDF.repository.TeamRepository; @Controller @Tag(name = "User", description = "User APIs") @@ -51,22 +53,8 @@ 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") - public String register(@ModelAttribute UsernameAndPass requestModel, Model model) - throws SQLException, UnsupportedProviderException { - if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) { - model.addAttribute("error", "Username already exists"); - return "register"; - } - try { - userService.saveUser(requestModel.getUsername(), requestModel.getPassword()); - } catch (IllegalArgumentException e) { - return "redirect:/login?messageType=invalidUsername"; - } - return "redirect:/login?registered=true"; - } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-username") @@ -187,6 +175,7 @@ public class UserController { public RedirectView saveUser( @RequestParam(name = "username", required = true) String username, @RequestParam(name = "password", required = false) String password, + @RequestParam(name = "teamId", required = false) Long teamId, @RequestParam(name = "role") String role, @RequestParam(name = "authType") String authType, @RequestParam(name = "forceChange", required = false, defaultValue = "false") @@ -221,14 +210,21 @@ public class UserController { // If the role ID is not valid, redirect with an error message return new RedirectView("/adminSettings?messageType=invalidRole", true); } + Optional team = teamId != null ? teamRepository.findById(teamId) : Optional.empty(); + User newUser; + if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) { - userService.saveUser(username, AuthenticationType.SSO, role); + newUser = userService.saveUser(username, AuthenticationType.SSO, teamId,role); } else { if (password.isBlank()) { return new RedirectView("/adminSettings?messageType=invalidPassword", true); } - userService.saveUser(username, password, role, forceChange); + newUser = userService.saveUser(username, password, teamId, role, forceChange); } + + team.ifPresent(newUser::setTeam); + userService.saveUser(newUser); // Persist with team + return new RedirectView( "/adminSettings", // Redirect to account page after adding the user true); @@ -374,4 +370,20 @@ public class UserController { } return ResponseEntity.ok(apiKey); } + + @PostMapping("/admin/changeTeam") + @PreAuthorize("hasRole('ROLE_ADMIN')") + public String changeUserTeam(@RequestParam String username, @RequestParam Long teamId) { + Optional user = userService.findByUsernameIgnoreCase(username); + Optional team = teamRepository.findById(teamId); + if (user.isPresent() && team.isPresent()) { + user.get().setTeam(team.get()); + userService.saveUser(user.get()); + return "redirect:/adminSettings?messageType=teamChanged"; + } + + return "redirect:/adminSettings?messageType=userNotFound"; + } + + } diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 327cda76c..5fa1c187b 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -19,6 +20,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; 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 com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -39,10 +41,12 @@ import stirling.software.SPDF.model.ApplicationProperties.Security.SAML2; import stirling.software.SPDF.model.Authority; import stirling.software.SPDF.model.Role; import stirling.software.SPDF.model.SessionEntity; +import stirling.software.SPDF.model.Team; import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.provider.GitHubProvider; import stirling.software.SPDF.model.provider.GoogleProvider; import stirling.software.SPDF.model.provider.KeycloakProvider; +import stirling.software.SPDF.repository.TeamRepository; import stirling.software.SPDF.repository.UserRepository; @Controller @@ -58,15 +62,20 @@ public class AccountWebController { 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 +219,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 @@ -321,6 +330,10 @@ public class AccountWebController { }; model.addAttribute("changeMessage", changeMessage); } + + List allTeams = teamRepository.findAll(); + model.addAttribute("teams", allTeams); + model.addAttribute("users", sortedUsers); model.addAttribute("currentUsername", authentication.getName()); @@ -444,5 +457,5 @@ public class AccountWebController { return "redirect:/"; } return "change-creds"; - } + } } diff --git a/src/main/java/stirling/software/SPDF/model/Team.java b/src/main/java/stirling/software/SPDF/model/Team.java new file mode 100644 index 000000000..6af5df853 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/model/Team.java @@ -0,0 +1,41 @@ +package stirling.software.SPDF.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "teams") +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) +public class Team implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_id") + private Long id; + + @Column(name = "name", unique = true, nullable = false) + private String name; + + @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) + private Set 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/src/main/java/stirling/software/SPDF/model/User.java b/src/main/java/stirling/software/SPDF/model/User.java index 1eb9da991..e6fb91da5 100644 --- a/src/main/java/stirling/software/SPDF/model/User.java +++ b/src/main/java/stirling/software/SPDF/model/User.java @@ -55,6 +55,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/src/main/java/stirling/software/SPDF/repository/TeamRepository.java b/src/main/java/stirling/software/SPDF/repository/TeamRepository.java new file mode 100644 index 000000000..cf368f297 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/repository/TeamRepository.java @@ -0,0 +1,20 @@ +package stirling.software.SPDF.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import stirling.software.SPDF.model.Team; + +@Repository +public interface TeamRepository extends JpaRepository { + Optional findByName(String name); + + @Query("SELECT t FROM Team t LEFT JOIN FETCH t.users") + List findAllWithUsers(); + + boolean existsByNameIgnoreCase(String name); +} \ No newline at end of file diff --git a/src/main/java/stirling/software/SPDF/repository/UserRepository.java b/src/main/java/stirling/software/SPDF/repository/UserRepository.java index e1f53efb8..fa42bd0c8 100644 --- a/src/main/java/stirling/software/SPDF/repository/UserRepository.java +++ b/src/main/java/stirling/software/SPDF/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.SPDF.model.Team; import stirling.software.SPDF.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/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index 6b2935747..200a20420 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -87,6 +87,8 @@ addToDoc=Add to Document reset=Reset apply=Apply noFileSelected=No file selected. Please upload one. +view=View +cancel=Cancel legal.privacy=Privacy Policy legal.terms=Terms and Conditions @@ -207,6 +209,9 @@ account.property=Property account.webBrowserSettings=Web Browser Setting account.syncToBrowser=Sync Account -> Browser account.syncToAccount=Sync Account <- Browser +account.adminTitle=Administrator Tools +account.adminNotif=You have admin privileges. Access system settings and user management. + adminUserSettings.title=User Control Settings @@ -227,7 +232,7 @@ adminUserSettings.webOnlyUser=Web Only User adminUserSettings.demoUser=Demo User (No custom settings) adminUserSettings.internalApiUser=Internal API User adminUserSettings.forceChange=Force user to change password on login -adminUserSettings.submit=Save User +adminUserSettings.submit=Save adminUserSettings.changeUserRole=Change User's Role adminUserSettings.authenticated=Authenticated adminUserSettings.editOwnProfil=Edit own profile @@ -239,6 +244,26 @@ 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 diff --git a/src/main/resources/static/css/modern-tables.css b/src/main/resources/static/css/modern-tables.css new file mode 100644 index 000000000..8acd8360a --- /dev/null +++ b/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/src/main/resources/templates/account.html b/src/main/resources/templates/account.html index 662a4f72c..3065263b0 100644 --- a/src/main/resources/templates/account.html +++ b/src/main/resources/templates/account.html @@ -1,7 +1,8 @@ - + + @@ -9,353 +10,457 @@
-

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

+ + settings_account_box + + User Settings +

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

User!

- -