From ac88125bf54c01b6d451a696e98e5f66b230013e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:11:51 +0000 Subject: [PATCH] team fuctionality --- .../common/model/ApplicationProperties.java | 1 + .../controller/api/misc/ConfigController.java | 3 + .../src/main/resources/settings.yml.template | 1 + .../api/ProprietaryUIDataController.java | 2 +- .../configuration/SecurityConfiguration.java | 7 +- .../controller/api/DatabaseController.java | 126 +++- .../controller/api/TeamController.java | 71 +- .../controller/api/UserController.java | 333 +++++++-- .../security/filter/FirstLoginFilter.java | 77 -- .../proprietary/security/model/User.java | 2 +- .../security/service/EmailService.java | 85 +++ .../public/locales/en-GB/translation.json | 189 +++++ frontend/src/auth/springAuthClient.ts | 1 + .../shared/DismissAllErrorsButton.tsx | 3 +- .../src/components/shared/FirstLoginModal.tsx | 159 +++++ .../shared/config/configNavSections.tsx | 19 + .../AdminConnectionsSection.tsx | 1 + .../configSections/AdminMailSection.tsx | 22 +- .../config/configSections/PeopleSection.tsx | 673 ++++++++++++++++++ .../configSections/TeamDetailsSection.tsx | 477 +++++++++++++ .../config/configSections/TeamsSection.tsx | 456 ++++++++++++ .../src/components/toast/ToastRenderer.css | 2 +- frontend/src/hooks/useAppConfig.ts | 1 + frontend/src/routes/Landing.tsx | 51 +- frontend/src/services/accountService.ts | 34 + frontend/src/services/httpErrorHandler.ts | 4 + frontend/src/services/teamService.ts | 107 +++ .../src/services/userManagementService.ts | 166 +++++ frontend/src/styles/zIndex.ts | 3 + 29 files changed, 2856 insertions(+), 220 deletions(-) delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java create mode 100644 frontend/src/components/shared/FirstLoginModal.tsx create mode 100644 frontend/src/components/shared/config/configSections/PeopleSection.tsx create mode 100644 frontend/src/components/shared/config/configSections/TeamDetailsSection.tsx create mode 100644 frontend/src/components/shared/config/configSections/TeamsSection.tsx create mode 100644 frontend/src/services/accountService.ts create mode 100644 frontend/src/services/teamService.ts create mode 100644 frontend/src/services/userManagementService.ts diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 0f968ba370..30c41df28e 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -534,6 +534,7 @@ public class ApplicationProperties { @Data public static class Mail { private boolean enabled; + private boolean enableInvites = false; private String host; private int port; private String username; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index ef490810f3..27c72eaf0b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -62,6 +62,9 @@ public class ConfigController { // Security settings configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin()); + // Mail settings + configData.put("enableEmailInvites", applicationProperties.getMail().isEnableInvites()); + // Check if user is admin using UserServiceInterface boolean isAdmin = false; if (userService != null) { diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index d0cdd3d8d8..602116e4fc 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -99,6 +99,7 @@ premium: mail: enabled: false # set to 'true' to enable sending emails + enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin) host: smtp.example.com # SMTP server hostname port: 587 # SMTP server port username: '' # SMTP server username diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 5f321a89e3..5735027f6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -53,7 +53,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry; @Slf4j @ProprietaryUiDataApi -@EnterpriseEndpoint public class ProprietaryUIDataController { private final ApplicationProperties applicationProperties; @@ -89,6 +88,7 @@ public class ProprietaryUIDataController { @GetMapping("/audit-dashboard") @PreAuthorize("hasRole('ADMIN')") + @EnterpriseEndpoint @Operation(summary = "Get audit dashboard data") public ResponseEntity getAuditDashboardData() { AuditDashboardData data = new AuditDashboardData(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index b7e51fe2cd..92def884fd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -39,7 +39,6 @@ import stirling.software.proprietary.security.CustomLogoutSuccessHandler; import stirling.software.proprietary.security.JwtAuthenticationEntryPoint; import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl; import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; -import stirling.software.proprietary.security.filter.FirstLoginFilter; import stirling.software.proprietary.security.filter.IPRateLimitingFilter; import stirling.software.proprietary.security.filter.JwtAuthenticationFilter; import stirling.software.proprietary.security.filter.UserAuthenticationFilter; @@ -74,7 +73,6 @@ public class SecurityConfiguration { private final JwtServiceInterface jwtService; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final LoginAttemptService loginAttemptService; - private final FirstLoginFilter firstLoginFilter; private final SessionPersistentRegistry sessionRegistry; private final PersistentLoginRepository persistentLoginRepository; private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; @@ -93,7 +91,6 @@ public class SecurityConfiguration { JwtServiceInterface jwtService, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, - FirstLoginFilter firstLoginFilter, SessionPersistentRegistry sessionRegistry, @Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper, @Autowired(required = false) @@ -110,7 +107,6 @@ public class SecurityConfiguration { this.jwtService = jwtService; this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.loginAttemptService = loginAttemptService; - this.firstLoginFilter = firstLoginFilter; this.sessionRegistry = sessionRegistry; this.persistentLoginRepository = persistentLoginRepository; this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper; @@ -135,8 +131,7 @@ public class SecurityConfiguration { http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore( - rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class); + rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); if (v2Enabled) { http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java index 9a3bcf839a..9fe3529aee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java @@ -2,21 +2,19 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; import java.io.InputStream; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import org.eclipse.jetty.http.HttpStatus; import org.springframework.context.annotation.Conditional; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; @@ -42,15 +40,19 @@ public class DatabaseController { summary = "Import a database backup file", description = "Uploads and imports a database backup SQL file.") @PostMapping(consumes = "multipart/form-data", value = "import-database") - public String importDatabase( + public ResponseEntity importDatabase( @Parameter(description = "SQL file to import", required = true) @RequestParam("fileInput") - MultipartFile file, - RedirectAttributes redirectAttributes) + MultipartFile file) throws IOException { if (file == null || file.isEmpty()) { - redirectAttributes.addAttribute("error", "fileNullOrEmpty"); - return "redirect:/database"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + java.util.Map.of( + "error", + "fileNullOrEmpty", + "message", + "File is null or empty")); } log.info("Received file: {}", file.getOriginalFilename()); Path tempTemplatePath = Files.createTempFile("backup_", ".sql"); @@ -58,15 +60,31 @@ public class DatabaseController { Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING); boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath); if (importSuccess) { - redirectAttributes.addAttribute("infoMessage", "importIntoDatabaseSuccessed"); + return ResponseEntity.ok( + java.util.Map.of( + "message", + "importIntoDatabaseSuccessed", + "description", + "Database imported successfully")); } else { - redirectAttributes.addAttribute("error", "failedImportFile"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "failedImportFile", + "message", + "Failed to import database file")); } } catch (Exception e) { log.error("Error importing database: {}", e.getMessage()); - redirectAttributes.addAttribute("error", "failedImportFile"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "failedImportFile", + "message", + "Failed to import database: " + e.getMessage())); } - return "redirect:/database"; } @Hidden @@ -74,11 +92,17 @@ public class DatabaseController { summary = "Import database backup by filename", description = "Imports a database backup file from the server using its file name.") @GetMapping("/import-database-file/{fileName}") - public String importDatabaseFromBackupUI( + public ResponseEntity importDatabaseFromBackupUI( @Parameter(description = "Name of the file to import", required = true) @PathVariable String fileName) { if (fileName == null || fileName.isEmpty()) { - return "redirect:/database?error=fileNullOrEmpty"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + java.util.Map.of( + "error", + "fileNullOrEmpty", + "message", + "File name is null or empty")); } // Check if the file exists in the backup list boolean fileExists = @@ -86,14 +110,31 @@ public class DatabaseController { .anyMatch(backup -> backup.getFileName().equals(fileName)); if (!fileExists) { log.error("File {} not found in backup list", fileName); - return "redirect:/database?error=fileNotFound"; + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + java.util.Map.of( + "error", + "fileNotFound", + "message", + "File not found in backup list")); } log.info("Received file: {}", fileName); if (databaseService.importDatabaseFromUI(fileName)) { log.info("File {} imported to database", fileName); - return "redirect:/database?infoMessage=importIntoDatabaseSuccessed"; + return ResponseEntity.ok( + java.util.Map.of( + "message", + "importIntoDatabaseSuccessed", + "description", + "Database backup imported successfully")); } - return "redirect:/database?error=failedImportFile"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "failedImportFile", + "message", + "Failed to import database file")); } @Hidden @@ -101,24 +142,42 @@ public class DatabaseController { summary = "Delete a database backup file", description = "Deletes a specified database backup file from the server.") @GetMapping("/delete/{fileName}") - public String deleteFile( + public ResponseEntity deleteFile( @Parameter(description = "Name of the file to delete", required = true) @PathVariable String fileName) { if (fileName == null || fileName.isEmpty()) { - throw new IllegalArgumentException("File must not be null or empty"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + java.util.Map.of( + "error", + "invalidFileName", + "message", + "File must not be null or empty")); } try { if (databaseService.deleteBackupFile(fileName)) { log.info("Deleted file: {}", fileName); + return ResponseEntity.ok(java.util.Map.of("message", "File deleted successfully")); } else { log.error("Failed to delete file: {}", fileName); - return "redirect:/database?error=failedToDeleteFile"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "failedToDeleteFile", + "message", + "Failed to delete backup file")); } } catch (IOException e) { log.error("Error deleting file: {}", e.getMessage()); - return "redirect:/database?error=" + e.getMessage(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "deleteError", + "message", + "Error deleting file: " + e.getMessage())); } - return "redirect:/database"; } @Hidden @@ -142,22 +201,29 @@ public class DatabaseController { .body(resource); } catch (IOException e) { log.error("Error downloading file: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.SEE_OTHER_303) - .location(URI.create("/database?error=downloadFailed")) - .build(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "downloadFailed", + "message", + "Failed to download file: " + e.getMessage())); } } @Operation( summary = "Create a database backup", - description = - "This endpoint triggers the creation of a database backup and redirects to the" - + " database management page.") + description = "This endpoint triggers the creation of a database backup.") @GetMapping("/createDatabaseBackup") - public String createDatabaseBackup() { + public ResponseEntity createDatabaseBackup() { log.info("Starting database backup creation..."); databaseService.exportDatabase(); log.info("Database backup successfully created."); - return "redirect:/database?infoMessage=backupCreated"; + return ResponseEntity.ok( + java.util.Map.of( + "message", + "backupCreated", + "description", + "Database backup created successfully")); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index 84066ec698..e4e9c1e87b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -1,10 +1,12 @@ package stirling.software.proprietary.security.controller.api; +import java.util.Map; import java.util.Optional; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.view.RedirectView; import jakarta.transaction.Transactional; @@ -30,98 +32,113 @@ public class TeamController { @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/create") - public RedirectView createTeam(@RequestParam("name") String name) { + public ResponseEntity createTeam(@RequestParam("name") String name) { if (teamRepository.existsByNameIgnoreCase(name)) { - return new RedirectView("/teams?messageType=teamExists"); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Team name already exists.")); } Team team = new Team(); team.setName(name); teamRepository.save(team); - return new RedirectView("/teams?messageType=teamCreated"); + return ResponseEntity.ok(Map.of("message", "Team created successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/rename") - public RedirectView renameTeam( + public ResponseEntity renameTeam( @RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) { Optional existing = teamRepository.findById(teamId); if (existing.isEmpty()) { - return new RedirectView("/teams?messageType=teamNotFound"); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Team not found.")); } if (teamRepository.existsByNameIgnoreCase(newName)) { - return new RedirectView("/teams?messageType=teamNameExists"); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Team name already exists.")); } Team team = existing.get(); // Prevent renaming the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return new RedirectView("/teams?messageType=internalTeamNotAccessible"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot rename Internal team.")); } team.setName(newName); teamRepository.save(team); - return new RedirectView("/teams?messageType=teamRenamed"); + return ResponseEntity.ok(Map.of("message", "Team renamed successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/delete") @Transactional - public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) { + public ResponseEntity deleteTeam(@RequestParam("teamId") Long teamId) { Optional teamOpt = teamRepository.findById(teamId); if (teamOpt.isEmpty()) { - return new RedirectView("/teams?messageType=teamNotFound"); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Team not found.")); } Team team = teamOpt.get(); // Prevent deleting the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return new RedirectView("/teams?messageType=internalTeamNotAccessible"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot delete Internal team.")); } long memberCount = userRepository.countByTeam(team); if (memberCount > 0) { - return new RedirectView("/teams?messageType=teamHasUsers"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "Team must be empty before deletion. Please remove all members first.")); } teamRepository.delete(team); - return new RedirectView("/teams?messageType=teamDeleted"); + return ResponseEntity.ok(Map.of("message", "Team deleted successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/addUser") @Transactional - public RedirectView addUserToTeam( + public ResponseEntity addUserToTeam( @RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) { // Find the team - Team team = - teamRepository - .findById(teamId) - .orElseThrow(() -> new RuntimeException("Team not found")); + Optional teamOpt = teamRepository.findById(teamId); + if (teamOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Team not found.")); + } + Team team = teamOpt.get(); // Prevent adding users to the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return new RedirectView("/teams?error=internalTeamNotAccessible"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot add users to Internal team.")); } // Find the user - User user = - userRepository - .findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); + } + User user = userOpt.get(); // Check if user is in the Internal team - prevent moving them if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot move users from Internal team.")); } // Assign user to team user.setTeam(team); userRepository.save(user); - // Redirect back to team details page - return new RedirectView("/teams/" + teamId + "?messageType=userAdded"); + return ResponseEntity.ok(Map.of("message", "User added to team successfully")); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 6d4b803c2a..42cb155c16 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -17,8 +17,6 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; -import org.springframework.web.servlet.view.RedirectView; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -38,6 +36,7 @@ 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.EmailService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -53,6 +52,7 @@ public class UserController { private final ApplicationProperties applicationProperties; private final TeamRepository teamRepository; private final UserRepository userRepository; + private final Optional emailService; @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") @@ -137,100 +137,130 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-username") - public RedirectView changeUsername( + public ResponseEntity changeUsername( Principal principal, @RequestParam(name = "currentPasswordChangeUsername") String currentPassword, @RequestParam(name = "newUsername") String newUsername, HttpServletRequest request, - HttpServletResponse response, - RedirectAttributes redirectAttributes) + HttpServletResponse response) throws IOException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(newUsername)) { - return new RedirectView("/account?messageType=invalidUsername", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "invalidUsername", "message", "Invalid username format")); } if (principal == null) { - return new RedirectView("/account?messageType=notAuthenticated", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); } // The username MUST be unique when renaming Optional userOpt = userService.findByUsername(principal.getName()); if (userOpt == null || userOpt.isEmpty()) { - return new RedirectView("/account?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "userNotFound", "message", "User not found")); } User user = userOpt.get(); if (user.getUsername().equals(newUsername)) { - return new RedirectView("/account?messageType=usernameExists", true); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "usernameExists", "message", "Username already in use")); } if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/account?messageType=incorrectPassword", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); } if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) { - return new RedirectView("/account?messageType=usernameExists", true); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "usernameExists", "message", "Username already exists")); } if (newUsername != null && newUsername.length() > 0) { try { userService.changeUsername(user, newUsername); } catch (IllegalArgumentException e) { - return new RedirectView("/account?messageType=invalidUsername", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "invalidUsername", + "message", + "Invalid username format")); } } // Logout using Spring's utility new SecurityContextLogoutHandler().logout(request, response, null); - return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true); + return ResponseEntity.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Username changed successfully. Please log in again.")); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-password-on-login") - public RedirectView changePasswordOnLogin( + public ResponseEntity changePasswordOnLogin( Principal principal, @RequestParam(name = "currentPassword") String currentPassword, @RequestParam(name = "newPassword") String newPassword, HttpServletRequest request, - HttpServletResponse response, - RedirectAttributes redirectAttributes) + HttpServletResponse response) throws SQLException, UnsupportedProviderException { if (principal == null) { - return new RedirectView("/change-creds?messageType=notAuthenticated", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); } Optional userOpt = userService.findByUsernameIgnoreCase(principal.getName()); if (userOpt.isEmpty()) { - return new RedirectView("/change-creds?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "userNotFound", "message", "User not found")); } User user = userOpt.get(); if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/change-creds?messageType=incorrectPassword", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); } userService.changePassword(user, newPassword); userService.changeFirstUse(user, false); // Logout using Spring's utility new SecurityContextLogoutHandler().logout(request, response, null); - return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true); + return ResponseEntity.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Password changed successfully. Please log in again.")); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-password") - public RedirectView changePassword( + public ResponseEntity changePassword( Principal principal, @RequestParam(name = "currentPassword") String currentPassword, @RequestParam(name = "newPassword") String newPassword, HttpServletRequest request, - HttpServletResponse response, - RedirectAttributes redirectAttributes) + HttpServletResponse response) throws SQLException, UnsupportedProviderException { if (principal == null) { - return new RedirectView("/account?messageType=notAuthenticated", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); } Optional userOpt = userService.findByUsernameIgnoreCase(principal.getName()); if (userOpt.isEmpty()) { - return new RedirectView("/account?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "userNotFound", "message", "User not found")); } User user = userOpt.get(); if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/account?messageType=incorrectPassword", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); } userService.changePassword(user, newPassword); // Logout using Spring's utility new SecurityContextLogoutHandler().logout(request, response, null); - return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true); + return ResponseEntity.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Password changed successfully. Please log in again.")); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @@ -248,23 +278,23 @@ public class UserController { * * Keys not listed above will be ignored. * @param principal The currently authenticated user. - * @return A redirect string to the account page after updating the settings. + * @return A ResponseEntity with success or error information. * @throws SQLException If a database error occurs. * @throws UnsupportedProviderException If the operation is not supported for the user's * provider. */ - public String updateUserSettings(@RequestBody Map updates, Principal principal) + public ResponseEntity updateUserSettings( + @RequestBody Map updates, Principal principal) throws SQLException, UnsupportedProviderException { log.debug("Processed updates: {}", updates); // Assuming you have a method in userService to update the settings for a user userService.updateUserSettings(principal.getName(), updates); - // Redirect to a page of your choice after updating - return "redirect:/account"; + return ResponseEntity.ok(Map.of("message", "Settings updated successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/saveUser") - public RedirectView saveUser( + public ResponseEntity saveUser( @RequestParam(name = "username", required = true) String username, @RequestParam(name = "password", required = false) String password, @RequestParam(name = "role") String role, @@ -274,33 +304,42 @@ public class UserController { boolean forceChange) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(username)) { - return new RedirectView("/adminSettings?messageType=invalidUsername", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "Invalid username format. Username must be 3-50 characters.")); } if (applicationProperties.getPremium().isEnabled() && applicationProperties.getPremium().getMaxUsers() <= userService.getTotalUsersCount()) { - return new RedirectView("/adminSettings?messageType=maxUsersReached", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Maximum number of users reached for your license.")); } Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isPresent()) { User user = userOpt.get(); if (user.getUsername().equalsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=usernameExists", true); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Username already exists.")); } } if (userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=usernameExists", true); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Username already exists.")); } try { // Validate the role Role roleEnum = Role.fromString(role); if (roleEnum == Role.INTERNAL_API_USER) { // If the role is INTERNAL_API_USER, reject the request - return new RedirectView("/adminSettings?messageType=invalidRole", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign INTERNAL_API_USER role.")); } } catch (IllegalArgumentException e) { - // If the role ID is not valid, redirect with an error message - return new RedirectView("/adminSettings?messageType=invalidRole", true); + // If the role ID is not valid, return error + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid role specified.")); } // Use teamId if provided, otherwise use default team @@ -316,28 +355,173 @@ public class UserController { Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); if (selectedTeam != null && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { - return new RedirectView( - "/adminSettings?messageType=internalTeamNotAccessible", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign users to Internal team.")); } } if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) { userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role); } else { - if (password.isBlank()) { - return new RedirectView("/adminSettings?messageType=invalidPassword", true); + if (password == null || password.isBlank()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password is required.")); + } + if (password.length() < 6) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password must be at least 6 characters.")); } userService.saveUser(username, password, effectiveTeamId, role, forceChange); } - return new RedirectView( - "/adminSettings", // Redirect to account page after adding the user - true); + return ResponseEntity.ok(Map.of("message", "User created successfully")); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/admin/inviteUsers") + public ResponseEntity inviteUsers( + @RequestParam(name = "emails", required = true) String emails, + @RequestParam(name = "role", defaultValue = "ROLE_USER") String role, + @RequestParam(name = "teamId", required = false) Long teamId) + throws SQLException, UnsupportedProviderException { + + // Check if email invites are enabled + if (!applicationProperties.getMail().isEnableInvites()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Email invites are not enabled")); + } + + // Check if email service is available + if (!emailService.isPresent()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body( + Map.of( + "error", + "Email service is not configured. Please configure SMTP settings.")); + } + + // Parse comma-separated email addresses + String[] emailArray = emails.split(","); + if (emailArray.length == 0) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "At least one email address is required")); + } + + // Check license limits + if (applicationProperties.getPremium().isEnabled()) { + long currentUserCount = userService.getTotalUsersCount(); + int maxUsers = applicationProperties.getPremium().getMaxUsers(); + long availableSlots = maxUsers - currentUserCount; + if (availableSlots < emailArray.length) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "Not enough user slots available. Available: " + + availableSlots + + ", Requested: " + + emailArray.length)); + } + } + + // Validate role + try { + Role roleEnum = Role.fromString(role); + if (roleEnum == Role.INTERNAL_API_USER) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign INTERNAL_API_USER role")); + } + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid role specified")); + } + + // Determine team + Long effectiveTeamId = teamId; + if (effectiveTeamId == null) { + Team defaultTeam = + teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); + if (defaultTeam != null) { + effectiveTeamId = defaultTeam.getId(); + } + } else { + Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); + if (selectedTeam != null + && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign users to Internal team")); + } + } + + int successCount = 0; + int failureCount = 0; + StringBuilder errors = new StringBuilder(); + + // Process each email + for (String email : emailArray) { + email = email.trim(); + if (email.isEmpty()) { + continue; + } + + try { + // Validate email format (basic check) + if (!email.contains("@") || !email.contains(".")) { + errors.append(email).append(": Invalid email format; "); + failureCount++; + continue; + } + + // Check if user already exists + if (userService.usernameExistsIgnoreCase(email)) { + errors.append(email).append(": User already exists; "); + failureCount++; + continue; + } + + // Generate random password + String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12); + + // Create user with forceChange=true + userService.saveUser(email, temporaryPassword, effectiveTeamId, role, true); + + // Send invite email + try { + emailService.get().sendInviteEmail(email, email, temporaryPassword); + successCount++; + log.info("Sent invite email to: {}", email); + } catch (Exception emailEx) { + log.error("Failed to send invite email to {}: {}", email, emailEx.getMessage()); + errors.append(email).append(": User created but email failed to send; "); + } + + } catch (Exception e) { + log.error("Failed to invite user {}: {}", email, e.getMessage()); + errors.append(email).append(": ").append(e.getMessage()).append("; "); + failureCount++; + } + } + + Map response = new HashMap<>(); + response.put("successCount", successCount); + response.put("failureCount", failureCount); + + if (failureCount > 0) { + response.put("errors", errors.toString()); + } + + if (successCount > 0) { + response.put("message", successCount + " user(s) invited successfully"); + return ResponseEntity.ok(response); + } else { + response.put("error", "Failed to invite any users"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/changeRole") @Transactional - public RedirectView changeRole( + public ResponseEntity changeRole( @RequestParam(name = "username") String username, @RequestParam(name = "role") String role, @RequestParam(name = "teamId", required = false) Long teamId, @@ -345,27 +529,32 @@ public class UserController { throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (!userOpt.isPresent()) { - return new RedirectView("/adminSettings?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=downgradeCurrentUser", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot change your own role.")); } try { // Validate the role Role roleEnum = Role.fromString(role); if (roleEnum == Role.INTERNAL_API_USER) { // If the role is INTERNAL_API_USER, reject the request - return new RedirectView("/adminSettings?messageType=invalidRole", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign INTERNAL_API_USER role.")); } } catch (IllegalArgumentException e) { - // If the role ID is not valid, redirect with an error message - return new RedirectView("/adminSettings?messageType=invalidRole", true); + // If the role ID is not valid, return error + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid role specified.")); } User user = userOpt.get(); @@ -375,15 +564,15 @@ public class UserController { if (team != null) { // Prevent assigning to Internal team if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) { - return new RedirectView( - "/adminSettings?messageType=internalTeamNotAccessible", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign users to Internal team.")); } // Prevent moving users from Internal team if (user.getTeam() != null && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { - return new RedirectView( - "/adminSettings?messageType=cannotMoveInternalUsers", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot move users from Internal team.")); } user.setTeam(team); @@ -392,30 +581,31 @@ public class UserController { } userService.changeRole(user, role); - return new RedirectView( - "/adminSettings", // Redirect to account page after adding the user - true); + return ResponseEntity.ok(Map.of("message", "User role updated successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/changeUserEnabled/{username}") - public RedirectView changeUserEnabled( + public ResponseEntity changeUserEnabled( @PathVariable("username") String username, @RequestParam("enabled") boolean enabled, Authentication authentication) throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isEmpty()) { - return new RedirectView("/adminSettings?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=disabledCurrentUser", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot disable your own account.")); } User user = userOpt.get(); userService.changeUserEnabled(user, enabled); @@ -442,23 +632,24 @@ public class UserController { } } } - return new RedirectView( - "/adminSettings", // Redirect to account page after adding the user - true); + return ResponseEntity.ok( + Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/deleteUser/{username}") - public RedirectView deleteUser( + public ResponseEntity deleteUser( @PathVariable("username") String username, Authentication authentication) { if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=deleteUsernameExists", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=deleteCurrentUser", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot delete your own account.")); } // Invalidate all sessions before deleting the user List sessionsInformations = @@ -468,7 +659,7 @@ public class UserController { sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId()); } userService.deleteUser(username); - return new RedirectView("/adminSettings", true); + return ResponseEntity.ok(Map.of("message", "User deleted successfully")); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java deleted file mode 100644 index 3bae721957..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java +++ /dev/null @@ -1,77 +0,0 @@ -package stirling.software.proprietary.security.filter; - -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Optional; - -import org.springframework.context.annotation.Lazy; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.util.RequestUriUtils; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.service.UserService; - -@Slf4j -@Component -public class FirstLoginFilter extends OncePerRequestFilter { - - @Lazy private final UserService userService; - - public FirstLoginFilter(@Lazy UserService userService) { - this.userService = userService; - } - - @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - String method = request.getMethod(); - String requestURI = request.getRequestURI(); - String contextPath = request.getContextPath(); - // Check if the request is for static resources - boolean isStaticResource = RequestUriUtils.isStaticResource(contextPath, requestURI); - // If it's a static resource, just continue the filter chain and skip the logic below - if (isStaticResource) { - filterChain.doFilter(request, response); - return; - } - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated()) { - Optional user = userService.findByUsernameIgnoreCase(authentication.getName()); - if ("GET".equalsIgnoreCase(method) - && user.isPresent() - && user.get().isFirstLogin() - && !(contextPath + "/change-creds").equals(requestURI)) { - response.sendRedirect(contextPath + "/change-creds"); - return; - } - } - if (log.isDebugEnabled()) { - HttpSession session = request.getSession(true); - SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss"); - String creationTime = timeFormat.format(new Date(session.getCreationTime())); - log.debug( - "Request Info - New: {}, creationTimeSession {}, ID: {}, IP: {}, User-Agent: {}, Referer: {}, Request URL: {}", - session.isNew(), - creationTime, - session.getId(), - request.getRemoteAddr(), - request.getHeader("User-Agent"), - request.getHeader("Referer"), - request.getRequestURL().toString()); - } - filterChain.doFilter(request, response); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 1c342bf5be..02bd08a5bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -73,7 +73,6 @@ public class User implements UserDetails, Serializable { @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "team_id") - @JsonIgnore private Team team; @ElementCollection @@ -81,6 +80,7 @@ public class User implements UserDetails, Serializable { @Lob @Column(name = "setting_value", columnDefinition = "text") @CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id")) + @JsonIgnore private Map settings = new HashMap<>(); // Key-value pairs of settings. @CreationTimestamp diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 08860a3407..d625d556fa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -73,4 +73,89 @@ public class EmailService { // Sends the email via the configured mail sender mailSender.send(message); } + + /** + * Sends a plain text/HTML email without attachments asynchronously. + * + * @param to The recipient email address + * @param subject The email subject + * @param body The email body (can contain HTML) + * @param isHtml Whether the body contains HTML content + * @throws MessagingException If there is an issue with creating or sending the email. + */ + @Async + public void sendPlainEmail(String to, String subject, String body, boolean isHtml) + throws MessagingException { + // Validate recipient email address + if (to == null || to.trim().isEmpty()) { + throw new MessagingException("Invalid recipient email address"); + } + + ApplicationProperties.Mail mailProperties = applicationProperties.getMail(); + + // Creates a MimeMessage to represent the email + MimeMessage message = mailSender.createMimeMessage(); + + // Helper class to set up the message content + MimeMessageHelper helper = new MimeMessageHelper(message, false); + + // Sets the recipient, subject, body, and sender email + helper.addTo(to); + helper.setSubject(subject); + helper.setText(body, isHtml); + helper.setFrom(mailProperties.getFrom()); + + // Sends the email via the configured mail sender + mailSender.send(message); + } + + /** + * Sends an invitation email to a new user with their credentials. + * + * @param to The recipient email address + * @param username The username for the new account + * @param temporaryPassword The temporary password + * @throws MessagingException If there is an issue with creating or sending the email. + */ + @Async + public void sendInviteEmail(String to, String username, String temporaryPassword) + throws MessagingException { + String subject = "Welcome to Stirling PDF"; + + String body = + String.format( + "" + + "
" + + "
" + + " " + + "
" + + " \"Stirling" + + "
" + + " " + + "
" + + "

Welcome to Stirling PDF!

" + + "

Hi there,

" + + "

You have been invited to join the workspace. Below are your login credentials:

" + + " " + + "
" + + "

Username: %s

" + + "

Temporary Password: %s

" + + "
" + + "
" + + "

⚠️ Important: You will be required to change your password upon first login for security reasons.

" + + "
" + + "

Please keep these credentials secure and do not share them with anyone.

" + + "

— The Stirling PDF Team

" + + "
" + + " " + + "
" + + " © 2025 Stirling PDF. All rights reserved." + + "
" + + "
" + + "
" + + "", + username, temporaryPassword); + + sendPlainEmail(to, subject, body, true); + } } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index e57374717b..480d9f0dfb 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -4391,5 +4391,194 @@ "finish": "Finish", "startTour": "Start Tour", "startTourDescription": "Take a guided tour of Stirling PDF's key features" + }, + "workspace": { + "title": "Workspace", + "people": { + "title": "People", + "description": "Manage workspace members and their permissions", + "searchMembers": "Search members...", + "addMembers": "Add Members", + "inviteMembers": "Invite Members", + "inviteMembers.subtitle": "Type or paste in emails below, separated by commas. Your workspace will be billed by members.", + "user": "User", + "role": "Role", + "team": "Team", + "status": "Status", + "actions": "Actions", + "noMembersFound": "No members found", + "active": "Active", + "disabled": "Disabled", + "activeSession": "Active session", + "member": "Member", + "admin": "Admin", + "roleDescriptions": { + "admin": "Can manage settings and invite members, with full administrative access.", + "member": "Can view and edit shared files, but cannot manage workspace settings or members." + }, + "editRole": "Edit Role", + "enable": "Enable", + "disable": "Disable", + "deleteUser": "Delete User", + "deleteUserSuccess": "User deleted successfully", + "deleteUserError": "Failed to delete user", + "confirmDelete": "Are you sure you want to delete this user? This action cannot be undone.", + "addMember": { + "title": "Add Member", + "username": "Username (Email)", + "usernamePlaceholder": "user@example.com", + "password": "Password", + "passwordPlaceholder": "Enter password", + "role": "Role", + "team": "Team (Optional)", + "teamPlaceholder": "Select a team", + "forcePasswordChange": "Force password change on first login", + "cancel": "Cancel", + "submit": "Add Member", + "usernameRequired": "Username and password are required", + "passwordTooShort": "Password must be at least 6 characters", + "success": "User created successfully", + "error": "Failed to create user" + }, + "editMember": { + "title": "Edit Member", + "editing": "Editing:", + "role": "Role", + "team": "Team (Optional)", + "teamPlaceholder": "Select a team", + "cancel": "Cancel", + "submit": "Update Member", + "success": "User updated successfully", + "error": "Failed to update user" + }, + "toggleEnabled": { + "success": "User status updated successfully", + "error": "Failed to update user status" + }, + "delete": { + "success": "User deleted successfully", + "error": "Failed to delete user" + }, + "emailInvite": { + "tab": "Email Invite", + "description": "Type or paste in emails below, separated by commas. Users will receive login credentials via email.", + "emails": "Email Addresses", + "emailsPlaceholder": "user1@example.com, user2@example.com", + "emailsRequired": "At least one email address is required", + "submit": "Send Invites", + "success": "user(s) invited successfully", + "partialSuccess": "Some invites failed", + "allFailed": "Failed to invite users", + "error": "Failed to send invites" + }, + "directInvite": { + "tab": "Direct Create" + }, + "inviteMode": { + "username": "Username", + "email": "Email", + "emailDisabled": "Email invites require SMTP configuration and mail.enableInvites=true in settings" + } + }, + "teams": { + "title": "Teams", + "description": "Manage teams and organize workspace members", + "loading": "Loading teams...", + "loadingDetails": "Loading team details...", + "createNewTeam": "Create New Team", + "teamName": "Team Name", + "totalMembers": "Total Members", + "actions": "Actions", + "noTeamsFound": "No teams found", + "noMembers": "No members in this team", + "system": "System", + "addMember": "Add Member", + "viewTeam": "View Team", + "removeMember": "Remove from team", + "cannotRemoveFromSystemTeam": "Cannot remove from system team", + "renameTeamLabel": "Rename Team", + "deleteTeamLabel": "Delete Team", + "cannotDeleteInternal": "Cannot delete the Internal team", + "confirmDelete": "Are you sure you want to delete this team? This team must be empty to delete.", + "confirmRemove": "Remove user from this team?", + "cannotRenameInternal": "Cannot rename the Internal team", + "cannotAddToInternal": "Cannot add members to the Internal team", + "teamNotFound": "Team not found", + "backToTeams": "Back to Teams", + "memberCount": "{{count}} members", + "removeMemberSuccess": "User removed from team", + "removeMemberError": "Failed to remove user from team", + "createTeam": { + "title": "Create New Team", + "teamName": "Team Name", + "teamNamePlaceholder": "Enter team name", + "cancel": "Cancel", + "submit": "Create Team", + "nameRequired": "Team name is required", + "success": "Team created successfully", + "error": "Failed to create team" + }, + "renameTeam": { + "title": "Rename Team", + "renaming": "Renaming:", + "newTeamName": "New Team Name", + "newTeamNamePlaceholder": "Enter new team name", + "cancel": "Cancel", + "submit": "Rename Team", + "nameRequired": "Team name is required", + "success": "Team renamed successfully", + "error": "Failed to rename team" + }, + "deleteTeam": { + "success": "Team deleted successfully", + "error": "Failed to delete team. Make sure the team is empty.", + "teamMustBeEmpty": "Team must be empty before deletion" + }, + "addMemberToTeam": { + "title": "Add Member to Team", + "addingTo": "Adding to", + "selectUser": "Select User", + "selectUserPlaceholder": "Choose a user", + "selectUserRequired": "Please select a user", + "currentlyIn": "currently in", + "willBeMoved": "Note: This user will be moved from their current team to this team.", + "cancel": "Cancel", + "submit": "Add Member", + "userRequired": "Please select a user", + "success": "Member added to team successfully", + "error": "Failed to add member to team" + }, + "changeTeam": { + "label": "Change Team", + "title": "Change Team", + "changing": "Moving", + "selectTeam": "Select Team", + "selectTeamPlaceholder": "Choose a team", + "selectTeamRequired": "Please select a team", + "success": "Team changed successfully", + "error": "Failed to change team", + "submit": "Change Team" + } + } + }, + "firstLogin": { + "title": "First Time Login", + "welcomeTitle": "Welcome!", + "welcomeMessage": "For security reasons, you must change your password on your first login.", + "loggedInAs": "Logged in as", + "error": "Error", + "currentPassword": "Current Password", + "enterCurrentPassword": "Enter your current password", + "newPassword": "New Password", + "enterNewPassword": "Enter new password (min 8 characters)", + "confirmPassword": "Confirm New Password", + "reEnterNewPassword": "Re-enter new password", + "changePassword": "Change Password", + "allFieldsRequired": "All fields are required", + "passwordsDoNotMatch": "New passwords do not match", + "passwordTooShort": "Password must be at least 8 characters", + "passwordMustBeDifferent": "New password must be different from current password", + "passwordChangedSuccess": "Password changed successfully! Please log in again.", + "passwordChangeFailed": "Failed to change password. Please check your current password." } } diff --git a/frontend/src/auth/springAuthClient.ts b/frontend/src/auth/springAuthClient.ts index 03ea08f0da..8567eb654e 100644 --- a/frontend/src/auth/springAuthClient.ts +++ b/frontend/src/auth/springAuthClient.ts @@ -15,6 +15,7 @@ export interface User { role: string; enabled?: boolean; is_anonymous?: boolean; + isFirstLogin?: boolean; app_metadata?: Record; } diff --git a/frontend/src/components/shared/DismissAllErrorsButton.tsx b/frontend/src/components/shared/DismissAllErrorsButton.tsx index 44a579f343..853e341aa0 100644 --- a/frontend/src/components/shared/DismissAllErrorsButton.tsx +++ b/frontend/src/components/shared/DismissAllErrorsButton.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useFileState } from '../../contexts/FileContext'; import { useFileActions } from '../../contexts/file/fileHooks'; import CloseIcon from '@mui/icons-material/Close'; +import { Z_INDEX_TOAST } from '../../styles/zIndex'; interface DismissAllErrorsButtonProps { className?: string; @@ -38,7 +39,7 @@ const DismissAllErrorsButton: React.FC = ({ classNa position: 'absolute', top: '1rem', right: '1rem', - zIndex: 1000, + zIndex: Z_INDEX_TOAST, pointerEvents: 'auto' }} > diff --git a/frontend/src/components/shared/FirstLoginModal.tsx b/frontend/src/components/shared/FirstLoginModal.tsx new file mode 100644 index 0000000000..92c293765b --- /dev/null +++ b/frontend/src/components/shared/FirstLoginModal.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { Modal, Stack, Text, PasswordInput, Button, Alert } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from './LocalIcon'; +import { accountService } from '../../services/accountService'; +import { alert } from '../toast'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex'; + +interface FirstLoginModalProps { + opened: boolean; + onPasswordChanged: () => void; + username: string; +} + +/** + * FirstLoginModal + * + * Forces first-time users to change their password. + * Cannot be dismissed until password is successfully changed. + */ +export default function FirstLoginModal({ opened, onPasswordChanged, username }: FirstLoginModalProps) { + const { t } = useTranslation(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async () => { + // Validation + if (!currentPassword || !newPassword || !confirmPassword) { + setError(t('firstLogin.allFieldsRequired', 'All fields are required')); + return; + } + + if (newPassword !== confirmPassword) { + setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match')); + return; + } + + if (newPassword.length < 8) { + setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters')); + return; + } + + if (newPassword === currentPassword) { + setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password')); + return; + } + + try { + setLoading(true); + setError(''); + + await accountService.changePassword(currentPassword, newPassword); + + alert({ + alertType: 'success', + title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.') + }); + + // Clear form + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + + // Wait a moment for the user to see the success message + // Then the backend will have logged them out, and onPasswordChanged will handle redirect + setTimeout(() => { + onPasswordChanged(); + }, 1500); + } catch (err: any) { + console.error('Failed to change password:', err); + setError( + err.response?.data?.message || + t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.') + ); + } finally { + setLoading(false); + } + }; + + return ( + {}} // Cannot close + title={t('firstLogin.title', 'First Time Login')} + closeOnClickOutside={false} + closeOnEscape={false} + withCloseButton={false} + centered + size="md" + zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} + > + + } + title={t('firstLogin.welcomeTitle', 'Welcome!')} + color="blue" + > + + {t( + 'firstLogin.welcomeMessage', + 'For security reasons, you must change your password on your first login.' + )} + + + + + {t('firstLogin.loggedInAs', 'Logged in as')}: {username} + + + {error && ( + } + title={t('firstLogin.error', 'Error')} + color="red" + > + {error} + + )} + + setCurrentPassword(e.currentTarget.value)} + required + /> + + setNewPassword(e.currentTarget.value)} + required + /> + + setConfirmPassword(e.currentTarget.value)} + required + /> + + + + + ); +} diff --git a/frontend/src/components/shared/config/configNavSections.tsx b/frontend/src/components/shared/config/configNavSections.tsx index 12fb14e950..ac539368bc 100644 --- a/frontend/src/components/shared/config/configNavSections.tsx +++ b/frontend/src/components/shared/config/configNavSections.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { NavKey } from './types'; import HotkeysSection from './configSections/HotkeysSection'; import GeneralSection from './configSections/GeneralSection'; +import PeopleSection from './configSections/PeopleSection'; +import TeamsSection from './configSections/TeamsSection'; import AdminGeneralSection from './configSections/AdminGeneralSection'; import AdminSecuritySection from './configSections/AdminSecuritySection'; import AdminConnectionsSection from './configSections/AdminConnectionsSection'; @@ -52,6 +54,23 @@ export const createConfigNavSections = ( }, ], }, + { + title: 'Workspace', + items: [ + { + key: 'people', + label: 'People', + icon: 'group-rounded', + component: + }, + { + key: 'teams', + label: 'Teams', + icon: 'groups-rounded', + component: + }, + ], + }, { title: 'Preferences', items: [ diff --git a/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx index 89addd101d..f2aed551d9 100644 --- a/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -33,6 +33,7 @@ interface ConnectionsSettingsData { }; mail?: { enabled?: boolean; + enableInvites?: boolean; host?: string; port?: number; username?: string; diff --git a/frontend/src/components/shared/config/configSections/AdminMailSection.tsx b/frontend/src/components/shared/config/configSections/AdminMailSection.tsx index 239202db96..860f13bb69 100644 --- a/frontend/src/components/shared/config/configSections/AdminMailSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminMailSection.tsx @@ -9,6 +9,7 @@ import PendingBadge from '../PendingBadge'; interface MailSettingsData { enabled?: boolean; + enableInvites?: boolean; host?: string; port?: number; username?: string; @@ -68,7 +69,7 @@ export default function AdminMailSection() { -
+
{t('admin.settings.mail.enabled', 'Enable Mail')} @@ -82,7 +83,24 @@ export default function AdminMailSection() { /> -
+
+ + +
+ {t('admin.settings.mail.enableInvites', 'Enable Email Invites')} + + {t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')} + +
+ + setSettings({ ...settings, enableInvites: e.target.checked })} + disabled={!settings.enabled} + /> + + +
([]); + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [inviteModalOpened, setInviteModalOpened] = useState(false); + const [editUserModalOpened, setEditUserModalOpened] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [processing, setProcessing] = useState(false); + const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct'); + + // Form state for direct invite + const [inviteForm, setInviteForm] = useState({ + username: '', + password: '', + role: 'ROLE_USER', + teamId: undefined as number | undefined, + forceChange: false, + }); + + // Form state for email invite + const [emailInviteForm, setEmailInviteForm] = useState({ + emails: '', + role: 'ROLE_USER', + teamId: undefined as number | undefined, + }); + + // Form state for edit user modal + const [editForm, setEditForm] = useState({ + role: 'ROLE_USER', + teamId: undefined as number | undefined, + }); + + useEffect(() => { + fetchData(); + }, []); + + useEffect(() => { + if (config) { + console.log('[PeopleSection] Email invites enabled:', config.enableEmailInvites); + } + }, [config]); + + const fetchData = async () => { + try { + setLoading(true); + const [adminData, teamsData] = await Promise.all([ + userManagementService.getUsers(), + teamService.getTeams(), + ]); + + // Enrich users with session data + const enrichedUsers = adminData.users.map(user => ({ + ...user, + isActive: adminData.userSessions[user.username] || false, + lastRequest: adminData.userLastRequest[user.username] || undefined, + })); + + setUsers(enrichedUsers); + setTeams(teamsData); + } catch (error) { + console.error('Failed to fetch people data:', error); + alert({ alertType: 'error', title: 'Failed to load people data' }); + } finally { + setLoading(false); + } + }; + + const handleInviteUser = async () => { + if (!inviteForm.username || !inviteForm.password) { + alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') }); + return; + } + + try { + setProcessing(true); + await userManagementService.createUser({ + username: inviteForm.username, + password: inviteForm.password, + role: inviteForm.role, + teamId: inviteForm.teamId, + authType: 'password', + forceChange: inviteForm.forceChange, + }); + alert({ alertType: 'success', title: t('workspace.people.addMember.success') }); + setInviteModalOpened(false); + setInviteForm({ + username: '', + password: '', + role: 'ROLE_USER', + teamId: undefined, + forceChange: false, + }); + fetchData(); + } catch (error: any) { + console.error('Failed to create user:', error); + const errorMessage = error.response?.data?.message || + error.response?.data?.error || + error.message || + t('workspace.people.addMember.error'); + alert({ alertType: 'error', title: errorMessage }); + } finally { + setProcessing(false); + } + }; + + const handleEmailInvite = async () => { + if (!emailInviteForm.emails.trim()) { + alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'At least one email address is required') }); + return; + } + + try { + setProcessing(true); + const response = await userManagementService.inviteUsers({ + emails: emailInviteForm.emails, + role: emailInviteForm.role, + teamId: emailInviteForm.teamId, + }); + + if (response.successCount > 0) { + alert({ + alertType: 'success', + title: t('workspace.people.emailInvite.success', `${response.successCount} user(s) invited successfully`) + }); + + if (response.failureCount > 0 && response.errors) { + alert({ + alertType: 'warning', + title: t('workspace.people.emailInvite.partialSuccess', 'Some invites failed'), + body: response.errors + }); + } + + setInviteModalOpened(false); + setEmailInviteForm({ + emails: '', + role: 'ROLE_USER', + teamId: undefined, + }); + fetchData(); + } else { + alert({ + alertType: 'error', + title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'), + body: response.errors || response.error + }); + } + } catch (error: any) { + console.error('Failed to invite users:', error); + const errorMessage = error.response?.data?.message || + error.response?.data?.error || + error.message || + t('workspace.people.emailInvite.error', 'Failed to send invites'); + alert({ alertType: 'error', title: errorMessage }); + } finally { + setProcessing(false); + } + }; + + const handleUpdateUserRole = async () => { + if (!selectedUser) return; + + try { + setProcessing(true); + await userManagementService.updateUserRole({ + username: selectedUser.username, + role: editForm.role, + teamId: editForm.teamId, + }); + alert({ alertType: 'success', title: t('workspace.people.editMember.success') }); + closeEditModal(); + fetchData(); + } catch (error: any) { + console.error('Failed to update user:', error); + const errorMessage = error.response?.data?.message || + error.response?.data?.error || + error.message || + t('workspace.people.editMember.error'); + alert({ alertType: 'error', title: errorMessage }); + } finally { + setProcessing(false); + } + }; + + const handleToggleEnabled = async (user: User) => { + try { + await userManagementService.toggleUserEnabled(user.username, !user.enabled); + alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') }); + fetchData(); + } catch (error: any) { + console.error('Failed to toggle user status:', error); + const errorMessage = error.response?.data?.message || + error.response?.data?.error || + error.message || + t('workspace.people.toggleEnabled.error'); + alert({ alertType: 'error', title: errorMessage }); + } + }; + + const handleDeleteUser = async (user: User) => { + const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.'); + if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { + return; + } + + try { + await userManagementService.deleteUser(user.username); + alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') }); + fetchData(); + } catch (error: any) { + console.error('Failed to delete user:', error); + const errorMessage = error.response?.data?.message || + error.response?.data?.error || + error.message || + t('workspace.people.deleteUserError', 'Failed to delete user'); + alert({ alertType: 'error', title: errorMessage }); + } + }; + + const openEditModal = (user: User) => { + setSelectedUser(user); + setEditForm({ + role: user.roleName, + teamId: user.team?.id, + }); + setEditUserModalOpened(true); + }; + + const closeEditModal = () => { + setEditUserModalOpened(false); + setSelectedUser(null); + setEditForm({ + role: 'ROLE_USER', + teamId: undefined, + }); + }; + + const filteredUsers = users.filter((user) => + user.username.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const roleOptions = [ + { + value: 'ROLE_ADMIN', + label: t('workspace.people.admin'), + description: t('workspace.people.roleDescriptions.admin', 'Can manage settings and invite members, with full administrative access.'), + icon: 'admin-panel-settings' + }, + { + value: 'ROLE_USER', + label: t('workspace.people.member'), + description: t('workspace.people.roleDescriptions.member', 'Can view and edit shared files, but cannot manage workspace settings or members.'), + icon: 'person' + }, + ]; + + const renderRoleOption = ({ option }: { option: any }) => ( + + +
+ {option.label} + + {option.description} + +
+
+ ); + + const teamOptions = teams.map((team) => ({ + value: team.id.toString(), + label: team.name, + })); + + if (loading) { + return ( + + + + {t('workspace.people.loading', 'Loading people...')} + + + ); + } + + return ( + +
+ + {t('workspace.people.title')} + + + {t('workspace.people.description')} + +
+ + {/* Header Actions */} + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.currentTarget.value)} + style={{ maxWidth: 300 }} + /> + + + + {/* Members Table */} + + + + + {t('workspace.people.user')} + {t('workspace.people.role')} + {t('workspace.people.team')} + {t('workspace.people.status')} + + + + + {filteredUsers.length === 0 ? ( + + + + {t('workspace.people.noMembersFound')} + + + + ) : ( + filteredUsers.map((user) => ( + + +
+ {user.isActive && ( +
+ )} +
+ + {user.username} + + {user.email && ( + + {user.email} + + )} +
+
+ + + + {(user.rolesAsString || '').includes('ROLE_ADMIN') ? t('workspace.people.admin') : t('workspace.people.member')} + + + + {user.team?.name || '—'} + + + + {user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')} + + + + + {/* Info icon with tooltip */} + + Authentication: {user.authenticationType || 'Unknown'} + + Last Activity: {user.lastRequest + ? new Date(user.lastRequest).toLocaleString() + : 'Never'} + +
+ } + multiline + w={220} + position="left" + withArrow + zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10} + > + + + + + + {/* Actions menu */} + + + + + + + + openEditModal(user)}>{t('workspace.people.editRole')} + : } + onClick={() => handleToggleEnabled(user)} + > + {user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')} + + + } onClick={() => handleDeleteUser(user)}> + {t('workspace.people.deleteUser')} + + + + +
+
+ )) + )} +
+
+
+ + {/* Add Member Modal */} + setInviteModalOpened(false)} + size="md" + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + centered + padding="xl" + withCloseButton={false} + > +
+ setInviteModalOpened(false)} + size="lg" + style={{ + position: 'absolute', + top: '-8px', + right: '-8px', + zIndex: 1 + }} + /> + + {/* Header with Icon */} + + + + {t('workspace.people.inviteMembers', 'Invite Members')} + + {inviteMode === 'email' && ( + + {t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')} + + )} + + + {/* Mode Toggle */} + +
+ setInviteMode(value as 'email' | 'direct')} + data={[ + { + label: t('workspace.people.inviteMode.username', 'Username'), + value: 'direct', + }, + { + label: t('workspace.people.inviteMode.email', 'Email'), + value: 'email', + disabled: !config?.enableEmailInvites, + }, + ]} + fullWidth + /> +
+
+ + {/* Email Mode */} + {inviteMode === 'email' && config?.enableEmailInvites && ( + <> +