([]);
+ const [isNavigating, setIsNavigating] = useState(false);
+
+ const processedLinks = useMemo(() => {
+ return links.map(link => ({
+ ...link,
+ scaledRect: {
+ left: link.rect.origin.x * scale,
+ top: link.rect.origin.y * scale,
+ width: link.rect.size.width * scale,
+ height: link.rect.size.height * scale,
+ }
+ }));
+ }, [links, scale]);
+
+ useEffect(() => {
+ const fetchLinks = async () => {
+ if (!annotation) return;
+
+ try {
+ // Use the annotation API's built-in filtering if available
+ const pageAnnotations = await annotation
+ .getPageAnnotations({
+ pageIndex,
+ // Try to filter for link annotations (type 2) if the API supports it
+ ...(annotation.getPageAnnotations.length > 1 ? { types: [2] } : {})
+ })
+ .toPromise();
+
+ // Filter for link annotations (type 2 is LINK in PDF spec) as fallback
+ const linkAnnotations = pageAnnotations.filter(
+ (ann: any) => ann.type === 2
+ ) as LinkAnnotation[];
+
+ if (linkAnnotations.length > 0) {
+ setLinks(linkAnnotations);
+ return;
+ }
+ } catch (error) {
+ console.error('[LinkLayer] Failed to fetch links from annotation API:', error);
+ }
+
+ if (pdfDocument) {
+ try {
+ // Try different methods to get link annotations
+ let pdfLinks: any[] = [];
+
+ if (pdfDocument.getPageAnnotations && typeof pdfDocument.getPageAnnotations === 'function') {
+ pdfLinks = await pdfDocument.getPageAnnotations(pageIndex);
+ } else if (pdfDocument.getAnnotations && typeof pdfDocument.getAnnotations === 'function') {
+ const allAnnotations = await pdfDocument.getAnnotations();
+ pdfLinks = allAnnotations.filter((ann: any) => ann.pageIndex === pageIndex && ann.type === 2);
+ } else if (pdfDocument.pages && pdfDocument.pages[pageIndex]) {
+ const page = pdfDocument.pages[pageIndex];
+ if (page.getAnnotations && typeof page.getAnnotations === 'function') {
+ pdfLinks = await page.getAnnotations();
+ }
+ }
+
+ const convertedLinks = pdfLinks.map((ann: any) => ({
+ id: ann.id || `pdf-link-${pageIndex}-${Math.random()}`,
+ type: ann.type || 2,
+ rect: ann.rect || ann,
+ target: ann.target || ann.action
+ })) as LinkAnnotation[];
+
+ setLinks(convertedLinks);
+ } catch (error) {
+ console.warn('[LinkLayer] Failed to get annotations from PDF document:', error);
+ }
+ } else {
+ console.warn('[LinkLayer] No annotation API or PDF document available');
+ }
+ };
+
+ fetchLinks();
+ }, [annotation, pageIndex, pdfDocument]);
+
+ const handleLinkClick = useCallback(async (link: LinkAnnotation) => {
+ if (isNavigating) return; // Prevent multiple simultaneous navigations
+
+ try {
+ setIsNavigating(true);
+
+ if (onLinkClick) {
+ onLinkClick(link.target);
+ return;
+ }
+
+ if (isInternalLink(link)) {
+ const targetPage = link.target?.destination?.pageIndex ??
+ link.target?.action?.destination?.pageIndex;
+ if (targetPage !== undefined && scroll) {
+ await scroll.scrollToPage({
+ pageNumber: targetPage + 1, // PDF pages are 1-indexed
+ behavior: 'smooth',
+ });
+ }
+ } else if (isExternalLink(link)) {
+ const uri = link.target?.uri ?? link.target?.action?.uri;
+ if (uri) {
+ window.open(uri, '_blank', 'noopener,noreferrer');
+ }
+ } else {
+ throw new Error(`Unsupported link type: ${link.target?.type}`);
+ }
+ } catch (error) {
+ console.error('[LinkLayer] Navigation failed:', error);
+ } finally {
+ setIsNavigating(false);
+ }
+ }, [isNavigating, onLinkClick, scroll]);
+
+ return (
+
+ {processedLinks.map((link) => {
+ const { id } = link;
+ const { left, top, width, height } = link.scaledRect;
+
+ return (
+
+ );
+};
diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx
index 2de2a2e588..adef8c4fbf 100644
--- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx
+++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx
@@ -43,6 +43,7 @@ import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
import { isPdfFile } from '@app/utils/fileUtils';
import { useTranslation } from 'react-i18next';
+import { LinkLayer } from '@app/components/viewer/LinkLayer';
interface LocalEmbedPDFProps {
file?: File | Blob;
@@ -103,15 +104,17 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
createPluginRegistration(SelectionPluginPackage),
// Register history plugin for undo/redo (recommended for annotations)
- ...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []),
+ // Always register for reading existing annotations
+ createPluginRegistration(HistoryPluginPackage),
// Register annotation plugin (depends on InteractionManager, Selection, History)
- ...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, {
+ // Always register for reading existing annotations like links
+ createPluginRegistration(AnnotationPluginPackage, {
annotationAuthor: 'Digital Signature',
autoCommit: true,
deactivateToolAfterCreate: false,
selectAfterCreate: true,
- })] : []),
+ }),
// Register pan plugin (depends on Viewport, InteractionManager)
createPluginRegistration(PanPluginPackage, {
@@ -229,68 +232,62 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{
+ onInitialized={async (registry) => {
const annotationPlugin = registry.getPlugin('annotation');
if (!annotationPlugin || !annotationPlugin.provides) return;
const annotationApi = annotationPlugin.provides();
if (!annotationApi) return;
- // Add custom signature stamp tool for image signatures
- annotationApi.addTool({
- id: 'signatureStamp',
- name: 'Digital Signature',
- interaction: { exclusive: false, cursor: 'copy' },
- matchScore: () => 0,
- defaults: {
- type: PdfAnnotationSubtype.STAMP,
- // Image will be set dynamically when signature is created
- },
- });
+ if (enableAnnotations) {
+ annotationApi.addTool({
+ id: 'signatureStamp',
+ name: 'Digital Signature',
+ interaction: { exclusive: false, cursor: 'copy' },
+ matchScore: () => 0,
+ defaults: {
+ type: PdfAnnotationSubtype.STAMP,
+ },
+ });
- // Add custom ink signature tool for drawn signatures
- annotationApi.addTool({
- id: 'signatureInk',
- name: 'Signature Draw',
- interaction: { exclusive: true, cursor: 'crosshair' },
- matchScore: () => 0,
- defaults: {
- type: PdfAnnotationSubtype.INK,
- color: '#000000',
- opacity: 1.0,
- borderWidth: 2,
- },
- });
+ annotationApi.addTool({
+ id: 'signatureInk',
+ name: 'Signature Draw',
+ interaction: { exclusive: true, cursor: 'crosshair' },
+ matchScore: () => 0,
+ defaults: {
+ type: PdfAnnotationSubtype.INK,
+ color: '#000000',
+ opacity: 1.0,
+ borderWidth: 2,
+ },
+ });
- // Listen for annotation events to track annotations and notify parent
- annotationApi.onAnnotationEvent((event: any) => {
- if (event.type === 'create' && event.committed) {
- // Add to annotations list
- setAnnotations(prev => [...prev, {
- id: event.annotation.id,
- pageIndex: event.pageIndex,
- rect: event.annotation.rect
- }]);
+ annotationApi.onAnnotationEvent((event: any) => {
+ if (event.type === 'create' && event.committed) {
+ setAnnotations(prev => [...prev, {
+ id: event.annotation.id,
+ pageIndex: event.pageIndex,
+ rect: event.annotation.rect
+ }]);
- // Notify parent if callback provided
- if (onSignatureAdded) {
- onSignatureAdded(event.annotation);
+ if (onSignatureAdded) {
+ onSignatureAdded(event.annotation);
+ }
+ } else if (event.type === 'delete' && event.committed) {
+ setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
+ } else if (event.type === 'loaded') {
+ const loadedAnnotations = event.annotations || [];
+ setAnnotations(loadedAnnotations.map((ann: any) => ({
+ id: ann.id,
+ pageIndex: ann.pageIndex || 0,
+ rect: ann.rect
+ })));
}
- } else if (event.type === 'delete' && event.committed) {
- // Remove from annotations list
- setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
- } else if (event.type === 'loaded') {
- // Handle initial load of annotations
- const loadedAnnotations = event.annotations || [];
- setAnnotations(loadedAnnotations.map((ann: any) => ({
- id: ann.id,
- pageIndex: ann.pageIndex || 0,
- rect: ann.rect
- })));
- }
- });
- } : undefined}
+ });
+ }
+ }}
>
@@ -352,6 +349,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{/* Selection layer for text interaction */}
+
+ {/* Link layer for clickable PDF links */}
+
+
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
Date: Sat, 29 Nov 2025 16:03:44 +0000
Subject: [PATCH 02/11] 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 c9b6e9d77d..6a565cade3 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 9e31ee69ce..f2b3fd5101 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 870c96f230..8df76fca3c 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.
+
+
+ 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 {