From 081b1ec49e70f6faaf634f4e24038f7194d395b7 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:35:41 +0000 Subject: [PATCH] Invite-link-issues (#5983) --- .../software/common/util/RequestUriUtils.java | 1 - .../controller/api/InviteLinkController.java | 31 +++++++++--------- .../api/InviteLinkControllerTest.java | 32 +++++++++++++++++-- .../components/shared/InviteMembersModal.tsx | 10 ++++-- .../services/userManagementService.ts | 4 +++ 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 6310154a8b..690a9213c8 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -86,7 +86,6 @@ public class RequestUriUtils { // Blocklist of backend/non-frontend paths that should still go through filters String[] backendOnlyPrefixes = { "/register", - "/invite", "/pipeline", "/pdfjs", "/pdfjs-legacy", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java index fc82c6e8c3..f49917a0b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java @@ -26,6 +26,7 @@ import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.SaveUserRequest; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.service.UserLicenseSettingsService; @InviteApi @Slf4j @@ -37,6 +38,7 @@ public class InviteLinkController { private final UserService userService; private final ApplicationProperties applicationProperties; private final Optional emailService; + private final UserLicenseSettingsService userLicenseSettingsService; /** * Generate a new invite link (admin only) @@ -58,6 +60,7 @@ public class InviteLinkController { @RequestParam(name = "teamId", required = false) Long teamId, @RequestParam(name = "expiryHours", required = false) Integer expiryHours, @RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail, + @RequestParam(name = "frontendBaseUrl", required = false) String frontendBaseUrl, Principal principal, HttpServletRequest request) { @@ -95,10 +98,6 @@ public class InviteLinkController { + " address")); } - // If sendEmail is requested but no email provided, reject - if (sendEmail) { - // Email will be sent - } } else { // No email provided - this is a general invite link email = null; // Ensure it's null, not empty string @@ -114,7 +113,7 @@ public class InviteLinkController { if (applicationProperties.getPremium().isEnabled()) { long currentUserCount = userService.getTotalUsersCount(); long activeInvites = inviteTokenRepository.countActiveInvites(LocalDateTime.now()); - int maxUsers = applicationProperties.getPremium().getMaxUsers(); + int maxUsers = userLicenseSettingsService.calculateMaxAllowedUsers(); if (currentUserCount + activeInvites >= maxUsers) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -180,19 +179,18 @@ public class InviteLinkController { inviteTokenRepository.save(inviteToken); - // Build invite URL - // Use configured frontend URL if available, otherwise fall back to backend URL + // Build invite URL: system.frontendUrl → caller's frontendBaseUrl → system.backendUrl → + // request URL String baseUrl; String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); + String configuredBackendUrl = applicationProperties.getSystem().getBackendUrl(); if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { - // Use configured frontend URL (remove trailing slash if present) - baseUrl = - configuredFrontendUrl.endsWith("/") - ? configuredFrontendUrl.substring( - 0, configuredFrontendUrl.length() - 1) - : configuredFrontendUrl; + baseUrl = configuredFrontendUrl.trim(); + } else if (frontendBaseUrl != null && !frontendBaseUrl.trim().isEmpty()) { + baseUrl = frontendBaseUrl.trim(); + } else if (configuredBackendUrl != null && !configuredBackendUrl.trim().isEmpty()) { + baseUrl = configuredBackendUrl.trim(); } else { - // Fall back to backend URL from request baseUrl = request.getScheme() + "://" @@ -201,7 +199,10 @@ public class InviteLinkController { ? ":" + request.getServerPort() : ""); } - String inviteUrl = baseUrl + "/invite?token=" + token; + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + String inviteUrl = baseUrl + "/invite/" + token; log.info("Generated invite link for {} by {}", email, principal.getName()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerTest.java index 2687969d8e..80e06f4d80 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerTest.java @@ -31,6 +31,7 @@ import stirling.software.proprietary.security.repository.TeamRepository; 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.service.UserLicenseSettingsService; @ExtendWith(MockitoExtension.class) class InviteLinkControllerTest { @@ -39,6 +40,7 @@ class InviteLinkControllerTest { @Mock private TeamRepository teamRepository; @Mock private UserService userService; @Mock private EmailService emailService; + @Mock private UserLicenseSettingsService userLicenseSettingsService; private ApplicationProperties applicationProperties; private MockMvc mockMvc; @@ -59,7 +61,8 @@ class InviteLinkControllerTest { teamRepository, userService, applicationProperties, - Optional.of(emailService)); + Optional.of(emailService), + userLicenseSettingsService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } @@ -89,9 +92,9 @@ class InviteLinkControllerTest { @Test void generateInviteLinkBlocksOnLicenseLimit() throws Exception { applicationProperties.getPremium().setEnabled(true); - applicationProperties.getPremium().setMaxUsers(1); when(userService.getTotalUsersCount()).thenReturn(1L); when(inviteTokenRepository.countActiveInvites(any(LocalDateTime.class))).thenReturn(0L); + when(userLicenseSettingsService.calculateMaxAllowedUsers()).thenReturn(1); mockMvc.perform( post("/api/v1/invite/generate") @@ -101,6 +104,29 @@ class InviteLinkControllerTest { .andExpect(jsonPath("$.error").value(startsWith("License limit reached"))); } + @Test + void generateInviteLinkAllowedOnServerLicense() throws Exception { + // SERVER license has raw maxUsers=0, but calculateMaxAllowedUsers() returns + // Integer.MAX_VALUE + applicationProperties.getPremium().setEnabled(true); + when(userService.getTotalUsersCount()).thenReturn(3L); + when(inviteTokenRepository.countActiveInvites(any(LocalDateTime.class))).thenReturn(0L); + when(userLicenseSettingsService.calculateMaxAllowedUsers()).thenReturn(Integer.MAX_VALUE); + when(userService.usernameExistsIgnoreCase("new@ex.com")).thenReturn(false); + when(inviteTokenRepository.findByEmail("new@ex.com")).thenReturn(Optional.empty()); + Team defaultTeam = new Team(); + defaultTeam.setId(1L); + defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME); + when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME)) + .thenReturn(Optional.of(defaultTeam)); + + mockMvc.perform( + post("/api/v1/invite/generate") + .principal(adminPrincipal) + .param("email", "new@ex.com")) + .andExpect(status().isOk()); + } + @Test void generateInviteLinkBuildsFrontendUrl() throws Exception { Team defaultTeam = new Team(); @@ -118,7 +144,7 @@ class InviteLinkControllerTest { .andExpect(status().isOk()) .andExpect( jsonPath("$.inviteUrl") - .value(startsWith("https://frontend.example.com/invite?token="))) + .value(startsWith("https://frontend.example.com/invite/"))) .andExpect(jsonPath("$.email").value("new@example.com")); verify(inviteTokenRepository).save(any()); diff --git a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx index e01b83399e..3298ca8e94 100644 --- a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx +++ b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal, @@ -38,6 +38,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit const [processing, setProcessing] = useState(false); const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct'); const [generatedInviteLink, setGeneratedInviteLink] = useState(null); + const actionTakenRef = useRef(false); // License information const [licenseInfo, setLicenseInfo] = useState<{ @@ -231,9 +232,10 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit teamId: inviteLinkForm.teamId, expiryHours: inviteLinkForm.expiryHours, sendEmail: inviteLinkForm.sendEmail, + frontendBaseUrl: config?.frontendUrl || window.location.origin, }); + actionTakenRef.current = true; setGeneratedInviteLink(response.inviteUrl); - onSuccess?.(); if (inviteLinkForm.sendEmail && inviteLinkForm.email) { alert({ alertType: 'success', title: t('workspace.people.inviteLink.emailSent', 'Invite link generated and sent via email') }); } @@ -247,6 +249,10 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit }; const handleClose = () => { + if (actionTakenRef.current) { + onSuccess?.(); + actionTakenRef.current = false; + } setGeneratedInviteLink(null); setInviteMode('direct'); setInviteForm({ diff --git a/frontend/src/proprietary/services/userManagementService.ts b/frontend/src/proprietary/services/userManagementService.ts index 8da746dd2d..11d7d66b9e 100644 --- a/frontend/src/proprietary/services/userManagementService.ts +++ b/frontend/src/proprietary/services/userManagementService.ts @@ -78,6 +78,7 @@ export interface InviteLinkRequest { teamId?: number; expiryHours?: number; sendEmail?: boolean; + frontendBaseUrl?: string; } export interface InviteLinkResponse { @@ -234,6 +235,9 @@ export const userManagementService = { if (data.sendEmail !== undefined) { formData.append('sendEmail', data.sendEmail.toString()); } + if (data.frontendBaseUrl) { + formData.append('frontendBaseUrl', data.frontendBaseUrl); + } const response = await apiClient.post( '/api/v1/invite/generate',