Invite-link-issues (#5983)

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