Fix email invite/ allow non auth and table refresh issues (#5076)

# Description of Changes

- Show warning when email invite fails but user is created
  - Auto-refresh user/team tables after modifications
  - Fix invite email URLs to use frontend URL instead of backend
  - Support anonymous SMTP for local development
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2025-11-29 16:03:44 +00:00
committed by GitHub
parent 85d9b5b83d
commit 4ae79d92ae
7 changed files with 106 additions and 21 deletions
@@ -35,15 +35,40 @@ public class MailConfig {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailProperties.getHost()); mailSender.setHost(mailProperties.getHost());
mailSender.setPort(mailProperties.getPort()); mailSender.setPort(mailProperties.getPort());
mailSender.setUsername(mailProperties.getUsername());
mailSender.setPassword(mailProperties.getPassword());
mailSender.setDefaultEncoding("UTF-8"); mailSender.setDefaultEncoding("UTF-8");
// Only set username and password if they are provided
String username = mailProperties.getUsername();
String password = mailProperties.getPassword();
boolean hasCredentials =
(username != null && !username.trim().isEmpty())
|| (password != null && !password.trim().isEmpty());
if (username != null && !username.trim().isEmpty()) {
mailSender.setUsername(username);
log.info("SMTP username configured");
} else {
log.info("SMTP username not configured - using anonymous connection");
}
if (password != null && !password.trim().isEmpty()) {
mailSender.setPassword(password);
log.info("SMTP password configured");
} else {
log.info("SMTP password not configured");
}
// Retrieves the JavaMail properties to configure additional SMTP parameters // Retrieves the JavaMail properties to configure additional SMTP parameters
Properties props = mailSender.getJavaMailProperties(); Properties props = mailSender.getJavaMailProperties();
// Enables SMTP authentication // Only enable SMTP authentication if credentials are provided
if (hasCredentials) {
props.put("mail.smtp.auth", "true"); props.put("mail.smtp.auth", "true");
log.info("SMTP authentication enabled");
} else {
props.put("mail.smtp.auth", "false");
log.info("SMTP authentication disabled - no credentials provided");
}
// Enables STARTTLS to encrypt the connection if supported by the SMTP server // Enables STARTTLS to encrypt the connection if supported by the SMTP server
props.put("mail.smtp.starttls.enable", "true"); props.put("mail.smtp.starttls.enable", "true");
@@ -407,7 +407,8 @@ public class UserController {
public ResponseEntity<?> inviteUsers( public ResponseEntity<?> inviteUsers(
@RequestParam(name = "emails", required = true) String emails, @RequestParam(name = "emails", required = true) String emails,
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role, @RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
@RequestParam(name = "teamId", required = false) Long teamId) @RequestParam(name = "teamId", required = false) Long teamId,
HttpServletRequest request)
throws SQLException, UnsupportedProviderException { throws SQLException, UnsupportedProviderException {
// Check if email invites are enabled // Check if email invites are enabled
@@ -477,6 +478,9 @@ public class UserController {
} }
} }
// Build login URL
String loginUrl = buildLoginUrl(request);
int successCount = 0; int successCount = 0;
int failureCount = 0; int failureCount = 0;
StringBuilder errors = new StringBuilder(); StringBuilder errors = new StringBuilder();
@@ -488,7 +492,7 @@ public class UserController {
continue; continue;
} }
InviteResult result = processEmailInvite(email, effectiveTeamId, role); InviteResult result = processEmailInvite(email, effectiveTeamId, role, loginUrl);
if (result.isSuccess()) { if (result.isSuccess()) {
successCount++; successCount++;
} else { } else {
@@ -687,15 +691,45 @@ public class UserController {
return ResponseEntity.ok(apiKey); return ResponseEntity.ok(apiKey);
} }
/**
* Helper method to build the login URL from the application configuration or request.
*
* @param request The HTTP request
* @return The login URL
*/
private String buildLoginUrl(HttpServletRequest request) {
String baseUrl;
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
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;
} else {
// Fall back to backend URL from request
baseUrl =
request.getScheme()
+ "://"
+ request.getServerName()
+ (request.getServerPort() != 80 && request.getServerPort() != 443
? ":" + request.getServerPort()
: "");
}
return baseUrl + "/login";
}
/** /**
* Helper method to process a single email invitation. * Helper method to process a single email invitation.
* *
* @param email The email address to invite * @param email The email address to invite
* @param teamId The team ID to assign the user to * @param teamId The team ID to assign the user to
* @param role The role to assign to the user * @param role The role to assign to the user
* @param loginUrl The URL to the login page
* @return InviteResult containing success status and optional error message * @return InviteResult containing success status and optional error message
*/ */
private InviteResult processEmailInvite(String email, Long teamId, String role) { private InviteResult processEmailInvite(
String email, Long teamId, String role, String loginUrl) {
try { try {
// Validate email format (basic check) // Validate email format (basic check)
if (!email.contains("@") || !email.contains(".")) { if (!email.contains("@") || !email.contains(".")) {
@@ -715,7 +749,7 @@ public class UserController {
// Send invite email // Send invite email
try { try {
emailService.get().sendInviteEmail(email, email, temporaryPassword); emailService.get().sendInviteEmail(email, email, temporaryPassword, loginUrl);
log.info("Sent invite email to: {}", email); log.info("Sent invite email to: {}", email);
return InviteResult.success(); return InviteResult.success();
} catch (Exception emailEx) { } catch (Exception emailEx) {
@@ -115,10 +115,12 @@ public class EmailService {
* @param to The recipient email address * @param to The recipient email address
* @param username The username for the new account * @param username The username for the new account
* @param temporaryPassword The temporary password * @param temporaryPassword The temporary password
* @param loginUrl The URL to the login page
* @throws MessagingException If there is an issue with creating or sending the email. * @throws MessagingException If there is an issue with creating or sending the email.
*/ */
@Async @Async
public void sendInviteEmail(String to, String username, String temporaryPassword) public void sendInviteEmail(
String to, String username, String temporaryPassword, String loginUrl)
throws MessagingException { throws MessagingException {
String subject = "Welcome to Stirling PDF"; String subject = "Welcome to Stirling PDF";
@@ -144,6 +146,14 @@ public class EmailService {
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; color: #856404;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p> <p style="margin: 0; color: #856404;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p>
</div> </div>
<!-- CTA Button -->
<div style="text-align: center; margin: 30px 0;">
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Log In to Stirling PDF</a>
</div>
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
%s
</div>
<p>Please keep these credentials secure and do not share them with anyone.</p> <p>Please keep these credentials secure and do not share them with anyone.</p>
<p style="margin-bottom: 0;">— The Stirling PDF Team</p> <p style="margin-bottom: 0;">— The Stirling PDF Team</p>
</div> </div>
@@ -155,7 +165,7 @@ public class EmailService {
</div> </div>
</body></html> </body></html>
""" """
.formatted(username, temporaryPassword); .formatted(username, temporaryPassword, loginUrl, loginUrl);
sendPlainEmail(to, subject, body, true); sendPlainEmail(to, subject, body, true);
} }
@@ -5261,7 +5261,7 @@ emailsPlaceholder = "[email protected], [email protected]"
emailsRequired = "At least one email address is required" emailsRequired = "At least one email address is required"
submit = "Send Invites" submit = "Send Invites"
success = "user(s) invited successfully" success = "user(s) invited successfully"
partialSuccess = "Some invites failed" partialFailure = "Some invites failed"
allFailed = "Failed to invite users" allFailed = "Failed to invite users"
error = "Failed to send invites" error = "Failed to send invites"
@@ -27,9 +27,10 @@ import { useNavigate } from 'react-router-dom';
interface InviteMembersModalProps { interface InviteMembersModalProps {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
onSuccess?: () => void;
} }
export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) { export default function InviteMembersModal({ opened, onClose, onSuccess }: InviteMembersModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { config } = useAppConfig(); const { config } = useAppConfig();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -136,6 +137,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
}); });
alert({ alertType: 'success', title: t('workspace.people.addMember.success') }); alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
onClose(); onClose();
onSuccess?.();
// Reset form // Reset form
setInviteForm({ setInviteForm({
username: '', username: '',
@@ -168,11 +170,23 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
}); });
if (response.successCount > 0) { if (response.successCount > 0) {
// Show success message
alert({ alert({
alertType: 'success', alertType: 'success',
title: t('workspace.people.emailInvite.success', { count: response.successCount, defaultValue: `Successfully invited ${response.successCount} user(s)` }) title: t('workspace.people.emailInvite.success', { count: response.successCount, defaultValue: `Successfully invited ${response.successCount} user(s)` })
}); });
// Show warning if there were partial failures
if (response.failureCount > 0 && response.errors) {
alert({
alertType: 'warning',
title: t('workspace.people.emailInvite.partialFailure', 'Some invites failed'),
body: response.errors
});
}
onClose(); onClose();
onSuccess?.();
setEmailInviteForm({ setEmailInviteForm({
emails: '', emails: '',
role: 'ROLE_USER', role: 'ROLE_USER',
@@ -208,6 +222,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
sendEmail: inviteLinkForm.sendEmail, sendEmail: inviteLinkForm.sendEmail,
}); });
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') });
} }
@@ -588,6 +588,7 @@ export default function PeopleSection() {
<InviteMembersModal <InviteMembersModal
opened={inviteModalOpened} opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)} onClose={() => setInviteModalOpened(false)}
onSuccess={fetchData}
/> />
{/* Edit User Modal */} {/* Edit User Modal */}
@@ -80,9 +80,9 @@ export default function TeamsSection() {
setProcessing(true); setProcessing(true);
await teamService.createTeam(newTeamName); await teamService.createTeam(newTeamName);
alert({ alertType: 'success', title: t('workspace.teams.createTeam.success') }); alert({ alertType: 'success', title: t('workspace.teams.createTeam.success') });
setCreateModalOpened(false);
setNewTeamName(''); setNewTeamName('');
fetchTeams(); setCreateModalOpened(false);
await fetchTeams();
} catch (error: any) { } catch (error: any) {
console.error('Failed to create team:', error); console.error('Failed to create team:', error);
const errorMessage = error.response?.data?.message || const errorMessage = error.response?.data?.message ||
@@ -105,10 +105,10 @@ export default function TeamsSection() {
setProcessing(true); setProcessing(true);
await teamService.renameTeam(selectedTeam.id, renameTeamName); await teamService.renameTeam(selectedTeam.id, renameTeamName);
alert({ alertType: 'success', title: t('workspace.teams.renameTeam.success') }); alert({ alertType: 'success', title: t('workspace.teams.renameTeam.success') });
setRenameModalOpened(false);
setSelectedTeam(null);
setRenameTeamName(''); setRenameTeamName('');
fetchTeams(); setSelectedTeam(null);
setRenameModalOpened(false);
await fetchTeams();
} catch (error: any) { } catch (error: any) {
console.error('Failed to rename team:', error); console.error('Failed to rename team:', error);
const errorMessage = error.response?.data?.message || const errorMessage = error.response?.data?.message ||
@@ -134,7 +134,7 @@ export default function TeamsSection() {
try { try {
await teamService.deleteTeam(team.id); await teamService.deleteTeam(team.id);
alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') }); alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') });
fetchTeams(); await fetchTeams();
} catch (error: any) { } catch (error: any) {
console.error('Failed to delete team:', error); console.error('Failed to delete team:', error);
const errorMessage = error.response?.data?.message || const errorMessage = error.response?.data?.message ||
@@ -182,10 +182,10 @@ export default function TeamsSection() {
setProcessing(true); setProcessing(true);
await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId)); await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId));
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') }); alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') });
setAddMemberModalOpened(false);
setSelectedTeam(null);
setSelectedUserId(''); setSelectedUserId('');
fetchTeams(); setSelectedTeam(null);
setAddMemberModalOpened(false);
await fetchTeams();
} catch (error) { } catch (error) {
console.error('Failed to add member to team:', error); console.error('Failed to add member to team:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') }); alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });