mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Invite-link-issues (#5983)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
+16
-15
@@ -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());
|
||||||
|
|
||||||
|
|||||||
+29
-3
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user