From 4ae79d92ae3c3a9a62881b8aeb47181d9d72909d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:03:44 +0000 Subject: [PATCH] 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. --- .../security/configuration/MailConfig.java | 33 +++++++++++++-- .../controller/api/UserController.java | 42 +++++++++++++++++-- .../security/service/EmailService.java | 14 ++++++- .../public/locales/en-GB/translation.toml | 2 +- .../components/shared/InviteMembersModal.tsx | 17 +++++++- .../config/configSections/PeopleSection.tsx | 1 + .../config/configSections/TeamsSection.tsx | 18 ++++---- 7 files changed, 106 insertions(+), 21 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java index c9b6e9d77..6a565cade 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java @@ -35,15 +35,40 @@ public class MailConfig { JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); mailSender.setHost(mailProperties.getHost()); mailSender.setPort(mailProperties.getPort()); - mailSender.setUsername(mailProperties.getUsername()); - mailSender.setPassword(mailProperties.getPassword()); 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 Properties props = mailSender.getJavaMailProperties(); - // Enables SMTP authentication - props.put("mail.smtp.auth", "true"); + // Only enable SMTP authentication if credentials are provided + if (hasCredentials) { + 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 props.put("mail.smtp.starttls.enable", "true"); 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 9e31ee69c..f2b3fd510 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 @@ -407,7 +407,8 @@ public class UserController { 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) + @RequestParam(name = "teamId", required = false) Long teamId, + HttpServletRequest request) throws SQLException, UnsupportedProviderException { // Check if email invites are enabled @@ -477,6 +478,9 @@ public class UserController { } } + // Build login URL + String loginUrl = buildLoginUrl(request); + int successCount = 0; int failureCount = 0; StringBuilder errors = new StringBuilder(); @@ -488,7 +492,7 @@ public class UserController { continue; } - InviteResult result = processEmailInvite(email, effectiveTeamId, role); + InviteResult result = processEmailInvite(email, effectiveTeamId, role, loginUrl); if (result.isSuccess()) { successCount++; } else { @@ -687,15 +691,45 @@ public class UserController { 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. * * @param email The email address to invite * @param teamId The team ID to assign the user to * @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 */ - private InviteResult processEmailInvite(String email, Long teamId, String role) { + private InviteResult processEmailInvite( + String email, Long teamId, String role, String loginUrl) { try { // Validate email format (basic check) if (!email.contains("@") || !email.contains(".")) { @@ -715,7 +749,7 @@ public class UserController { // Send invite email try { - emailService.get().sendInviteEmail(email, email, temporaryPassword); + emailService.get().sendInviteEmail(email, email, temporaryPassword, loginUrl); log.info("Sent invite email to: {}", email); return InviteResult.success(); } catch (Exception emailEx) { 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 870c96f23..8df76fca3 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 @@ -115,10 +115,12 @@ public class EmailService { * @param to The recipient email address * @param username The username for the new account * @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. */ @Async - public void sendInviteEmail(String to, String username, String temporaryPassword) + public void sendInviteEmail( + String to, String username, String temporaryPassword, String loginUrl) throws MessagingException { String subject = "Welcome to Stirling PDF"; @@ -144,6 +146,14 @@ public class EmailService {

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

+ +
+ Log In to Stirling PDF +
+

Or copy and paste this link in your browser:

+
+ %s +

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

— The Stirling PDF Team

@@ -155,7 +165,7 @@ public class EmailService { """ - .formatted(username, temporaryPassword); + .formatted(username, temporaryPassword, loginUrl, loginUrl); sendPlainEmail(to, subject, body, true); } diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index d1ef09e6f..3ff331b18 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5261,7 +5261,7 @@ 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" +partialFailure = "Some invites failed" allFailed = "Failed to invite users" error = "Failed to send invites" diff --git a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx index 9a13d18a2..f583f3cb7 100644 --- a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx +++ b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx @@ -27,9 +27,10 @@ import { useNavigate } from 'react-router-dom'; interface InviteMembersModalProps { opened: boolean; onClose: () => void; + onSuccess?: () => void; } -export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) { +export default function InviteMembersModal({ opened, onClose, onSuccess }: InviteMembersModalProps) { const { t } = useTranslation(); const { config } = useAppConfig(); const navigate = useNavigate(); @@ -136,6 +137,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod }); alert({ alertType: 'success', title: t('workspace.people.addMember.success') }); onClose(); + onSuccess?.(); // Reset form setInviteForm({ username: '', @@ -168,11 +170,23 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod }); if (response.successCount > 0) { + // Show success message alert({ alertType: 'success', 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(); + onSuccess?.(); setEmailInviteForm({ emails: '', role: 'ROLE_USER', @@ -208,6 +222,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod sendEmail: inviteLinkForm.sendEmail, }); 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') }); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 611912dbc..9c4f56ba0 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -588,6 +588,7 @@ export default function PeopleSection() { setInviteModalOpened(false)} + onSuccess={fetchData} /> {/* Edit User Modal */} diff --git a/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx index cfd6346ae..eb256bc20 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx @@ -80,9 +80,9 @@ export default function TeamsSection() { setProcessing(true); await teamService.createTeam(newTeamName); alert({ alertType: 'success', title: t('workspace.teams.createTeam.success') }); - setCreateModalOpened(false); setNewTeamName(''); - fetchTeams(); + setCreateModalOpened(false); + await fetchTeams(); } catch (error: any) { console.error('Failed to create team:', error); const errorMessage = error.response?.data?.message || @@ -105,10 +105,10 @@ export default function TeamsSection() { setProcessing(true); await teamService.renameTeam(selectedTeam.id, renameTeamName); alert({ alertType: 'success', title: t('workspace.teams.renameTeam.success') }); - setRenameModalOpened(false); - setSelectedTeam(null); setRenameTeamName(''); - fetchTeams(); + setSelectedTeam(null); + setRenameModalOpened(false); + await fetchTeams(); } catch (error: any) { console.error('Failed to rename team:', error); const errorMessage = error.response?.data?.message || @@ -134,7 +134,7 @@ export default function TeamsSection() { try { await teamService.deleteTeam(team.id); alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') }); - fetchTeams(); + await fetchTeams(); } catch (error: any) { console.error('Failed to delete team:', error); const errorMessage = error.response?.data?.message || @@ -182,10 +182,10 @@ export default function TeamsSection() { setProcessing(true); await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId)); alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') }); - setAddMemberModalOpened(false); - setSelectedTeam(null); setSelectedUserId(''); - fetchTeams(); + setSelectedTeam(null); + setAddMemberModalOpened(false); + await fetchTeams(); } catch (error) { console.error('Failed to add member to team:', error); alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });