From 7ab30d262911486cf0e6cf1522c36a2daf30d2c8 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:14:49 +0100 Subject: [PATCH] add file share to the top workbench bar and add shared signing (#6715) --- .../WorkflowParticipantController.java | 8 +- .../workflow/dto/SignDocumentRequest.java | 1 + .../service/MetadataEncryptionService.java | 22 + .../service/SigningFinalizationService.java | 11 +- .../service/WorkflowSessionService.java | 148 +- .../MetadataEncryptionServiceTest.java | 35 + .../SigningFinalizationServiceMoreTest.java | 9 + .../service/WorkflowSessionServiceTest.java | 70 + .../public/locales/en-GB/translation.toml | 6 +- .../public/locales/en-US/translation.toml | 143 +- frontend/editor/public/og-metadata.json | 6 + .../src/core/components/AppProviders.tsx | 33 +- .../annotation/shared/DrawingCanvas.tsx | 2 +- .../src/core/components/layout/Workbench.tsx | 21 + .../QuickAccessBarFooterExtensions.tsx | 14 - .../core/components/shared/QuickAccessBar.tsx | 1424 ----------------- .../shared/ShareManagementModal.tsx | 29 +- .../core/components/shared/WorkbenchBar.tsx | 12 + .../quickAccessBar/ActiveToolButton.tsx | 232 --- .../shared/quickAccessBar/QuickAccessBar.css | 990 ------------ .../shared/quickAccessBar/QuickAccessBar.ts | 83 - .../shared/quickAccessBar/useToursTooltip.ts | 89 -- .../shared/signing/ActiveSessionsPanel.tsx | 159 -- .../shared/signing/CompletedSessionsPanel.tsx | 117 -- .../shared/signing/CreateSessionFlow.tsx | 4 +- .../shared/signing/CreateSessionPanel.tsx | 107 -- .../shared/signing/SharedSigningLauncher.tsx | 53 + .../components/shared/signing/SignPopout.tsx | 1030 ------------ .../steps/ConfigureSignatureDefaultsStep.tsx | 5 +- .../signing/steps/ReviewSessionStep.tsx | 13 +- .../signing/steps/SelectDocumentStep.tsx | 9 +- .../signing/steps/SelectParticipantsStep.tsx | 5 +- .../src/core/components/tools/ToolPicker.tsx | 29 +- .../tools/certSign/CertificateSelector.tsx | 42 +- .../certSign/SessionDetailWorkbenchView.tsx | 519 ------ .../certSign/SignControlsStrip.module.css | 648 -------- .../tools/certSign/SignControlsStrip.tsx | 790 --------- .../certSign/SignRequestWorkbenchView.tsx | 461 ------ .../certSign/panels/SessionActionsPanel.tsx | 2 +- .../certSign/panels/SessionDetailPanel.tsx | 269 ++++ .../certSign/panels/SignControlsPanel.tsx | 532 ++++++ .../certSign/panels/SignRequestPanel.tsx | 357 +++++ .../certSign/steps/SignatureCreationStep.tsx | 12 +- .../components/tools/sign/SignSettings.tsx | 3 - .../tools/toolPicker/ToolButton.tsx | 12 + .../core/components/viewer/EmbedPdfViewer.tsx | 26 + .../core/components/viewer/LocalEmbedPDF.tsx | 102 +- .../viewer/LocalEmbedPDFWithAnnotations.tsx | 1006 ------------ .../viewer/SignaturePreviewLayer.tsx | 397 +++++ .../src/core/components/viewer/Viewer.tsx | 16 +- .../components/viewer/ViewerShareButton.tsx | 205 +++ .../src/core/components/viewer/viewerTypes.ts | 22 + .../core/contexts/SigningOverlayContext.tsx | 55 + .../core/data/useTranslatedToolRegistry.tsx | 15 + .../hooks/signing/useSigningBadgeCount.ts | 42 + .../signing/useSigningSessionController.ts | 472 ++++++ .../core/hooks/signing/useSigningSessions.ts | 73 +- .../core/hooks/signing/useSigningWorkbench.ts | 79 - .../src/core/services/signingSeenStore.ts | 52 + frontend/editor/src/core/tools/SharedSign.tsx | 361 +++++ frontend/editor/src/core/tools/Sign.tsx | 1 + .../src/core/tools/stamp/createStampTool.tsx | 17 + frontend/editor/src/core/types/toolId.ts | 1 + .../components/tools/sign/SignSettings.tsx | 3 - scripts/ignore_translation.toml | 3 - 65 files changed, 3539 insertions(+), 7975 deletions(-) delete mode 100644 frontend/editor/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx delete mode 100644 frontend/editor/src/core/components/shared/QuickAccessBar.tsx delete mode 100644 frontend/editor/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx delete mode 100644 frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessBar.css delete mode 100644 frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessBar.ts delete mode 100644 frontend/editor/src/core/components/shared/quickAccessBar/useToursTooltip.ts delete mode 100644 frontend/editor/src/core/components/shared/signing/ActiveSessionsPanel.tsx delete mode 100644 frontend/editor/src/core/components/shared/signing/CompletedSessionsPanel.tsx delete mode 100644 frontend/editor/src/core/components/shared/signing/CreateSessionPanel.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.tsx delete mode 100644 frontend/editor/src/core/components/shared/signing/SignPopout.tsx delete mode 100644 frontend/editor/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx delete mode 100644 frontend/editor/src/core/components/tools/certSign/SignControlsStrip.module.css delete mode 100644 frontend/editor/src/core/components/tools/certSign/SignControlsStrip.tsx delete mode 100644 frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx delete mode 100644 frontend/editor/src/core/components/viewer/LocalEmbedPDFWithAnnotations.tsx create mode 100644 frontend/editor/src/core/components/viewer/SignaturePreviewLayer.tsx create mode 100644 frontend/editor/src/core/components/viewer/ViewerShareButton.tsx create mode 100644 frontend/editor/src/core/contexts/SigningOverlayContext.tsx create mode 100644 frontend/editor/src/core/hooks/signing/useSigningBadgeCount.ts create mode 100644 frontend/editor/src/core/hooks/signing/useSigningSessionController.ts delete mode 100644 frontend/editor/src/core/hooks/signing/useSigningWorkbench.ts create mode 100644 frontend/editor/src/core/services/signingSeenStore.ts create mode 100644 frontend/editor/src/core/tools/SharedSign.tsx diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 45d3228fdb..5f903e4b56 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -404,18 +404,16 @@ public class WorkflowParticipantController { certSubmission.put("reason", request.getReason()); certSubmission.put("showLogo", request.getShowLogo()); - // Store certificate files as base64 + // Store the certificate keystores encrypted at rest. if (request.getP12File() != null && !request.getP12File().isEmpty()) { certSubmission.put( "p12Keystore", - java.util.Base64.getEncoder() - .encodeToString(request.getP12File().getBytes())); + metadataEncryptionService.encryptBytes(request.getP12File().getBytes())); } if (request.getJksFile() != null && !request.getJksFile().isEmpty()) { certSubmission.put( "jksKeystore", - java.util.Base64.getEncoder() - .encodeToString(request.getJksFile().getBytes())); + metadataEncryptionService.encryptBytes(request.getJksFile().getBytes())); } metadata.put("certificateSubmission", certSubmission); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java index fcc8684057..ec17eb5c1f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java @@ -32,6 +32,7 @@ public class SignDocumentRequest { private String password; private MultipartFile privateKeyFile; private MultipartFile certFile; + private MultipartFile jksFile; // Signature metadata (participant can override owner defaults) private String reason; // Participant's reason for signing diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java index 9241a9ca4e..0b2ba9ef11 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java @@ -96,6 +96,28 @@ public class MetadataEncryptionService { } } + /** + * Encodes raw bytes to Base64 and encrypts them for at-rest storage (e.g. keystore files held + * in JSONB metadata). Returns {@code null} for {@code null} input. + */ + public String encryptBytes(byte[] data) { + if (data == null) { + return null; + } + return encrypt(Base64.getEncoder().encodeToString(data)); + } + + /** + * Reverses {@link #encryptBytes}. Also accepts legacy values stored as plain Base64 before + * encryption was introduced — {@link #decrypt} returns those unchanged, so they still decode. + */ + public byte[] decryptBytes(String stored) { + if (stored == null) { + return null; + } + return Base64.getDecoder().decode(decrypt(stored)); + } + // ── Internals ─────────────────────────────────────────────────────────── private SecretKeySpec deriveKey() throws Exception { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index dc8469dc8e..30673ab5bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -1039,17 +1039,18 @@ public class SigningFinalizationService { metadataEncryptionService.decrypt(submission.getPassword())); } - // Decode base64 keystore bytes + // Decrypt + decode keystore bytes (supports both legacy plaintext base64 and + // encrypted values). var certNode = node.get("certificateSubmission"); if (certNode.has("p12Keystore")) { submission.setP12Keystore( - java.util.Base64.getDecoder() - .decode(certNode.get("p12Keystore").asText())); + metadataEncryptionService.decryptBytes( + certNode.get("p12Keystore").asText())); } if (certNode.has("jksKeystore")) { submission.setJksKeystore( - java.util.Base64.getDecoder() - .decode(certNode.get("jksKeystore").asText())); + metadataEncryptionService.decryptBytes( + certNode.get("jksKeystore").asText())); } return submission; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index dc22c40525..364dbf39ce 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -1,6 +1,13 @@ package stirling.software.proprietary.workflow.service; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStreamReader; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; @@ -8,6 +15,16 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMDecryptorProvider; +import org.bouncycastle.openssl.PEMEncryptedKeyPair; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; +import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; +import org.bouncycastle.operator.InputDecryptorProvider; +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -705,24 +722,70 @@ public class WorkflowSessionService { } } - // 2. Store certificate submission data - Map certSubmission = new HashMap<>(); - certSubmission.put("certType", request.getCertType()); - certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword())); - - // Store keystore files as base64 if provided - if (request.getP12File() != null && !request.getP12File().isEmpty()) { + // Validate an uploaded JKS keystore too (same early rejection as P12/PFX). + if ("JKS".equalsIgnoreCase(request.getCertType()) + && request.getJksFile() != null + && !request.getJksFile().isEmpty()) { try { - byte[] keystoreBytes = request.getP12File().getBytes(); - String base64Keystore = java.util.Base64.getEncoder().encodeToString(keystoreBytes); - certSubmission.put("p12Keystore", base64Keystore); + certificateSubmissionValidator.validateAndExtractInfo( + request.getJksFile().getBytes(), "JKS", request.getPassword()); + } catch (ResponseStatusException e) { + throw e; } catch (IOException e) { - log.error("Failed to read P12 keystore file", e); + log.error("Failed to read JKS keystore file for validation", e); throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Failed to process certificate file"); } } + // 2. Store certificate submission data + Map certSubmission = new HashMap<>(); + certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword())); + + if ("PEM".equalsIgnoreCase(request.getCertType())) { + // PEM uploads are a separate private key + certificate, not a keystore. Convert them to + // a PKCS12 keystore here so finalization signs via the standard PKCS12 path. + byte[] p12 = + buildPkcs12FromPem( + request.getPrivateKeyFile(), + request.getCertFile(), + request.getPassword()); + // Give PEM the same early validation (expiry, key recovery, test-sign) as uploaded + // PKCS12/JKS keystores, so an expired or unusable cert is rejected now rather than at + // finalization. + certificateSubmissionValidator.validateAndExtractInfo( + p12, "PKCS12", request.getPassword()); + certSubmission.put("certType", "PKCS12"); + // Store the keystore encrypted at rest. + certSubmission.put("p12Keystore", metadataEncryptionService.encryptBytes(p12)); + } else { + certSubmission.put("certType", request.getCertType()); + // Encrypt the uploaded keystore at rest: PKCS12/PFX → p12Keystore, JKS → jksKeystore. + if (request.getP12File() != null && !request.getP12File().isEmpty()) { + try { + certSubmission.put( + "p12Keystore", + metadataEncryptionService.encryptBytes( + request.getP12File().getBytes())); + } catch (IOException e) { + log.error("Failed to read P12 keystore file", e); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Failed to process certificate file"); + } + } else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) { + try { + certSubmission.put( + "jksKeystore", + metadataEncryptionService.encryptBytes( + request.getJksFile().getBytes())); + } catch (IOException e) { + log.error("Failed to read JKS keystore file", e); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Failed to process certificate file"); + } + } + } + // Note: Signature appearance settings (showSignature, pageNumber, location, reason, // showLogo) // may already be in metadata if owner configured them when adding participant. @@ -837,6 +900,69 @@ public class WorkflowSessionService { "User is not a participant in this session")); } + /** + * Converts an uploaded PEM private key + certificate into a PKCS12 keystore (protected with the + * supplied password) so finalization can sign via the standard PKCS12 path. + */ + private byte[] buildPkcs12FromPem( + MultipartFile privateKeyFile, MultipartFile certFile, String password) { + if (privateKeyFile == null + || privateKeyFile.isEmpty() + || certFile == null + || certFile.isEmpty()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "PEM signing requires both a private key file and a certificate file"); + } + char[] pw = password != null ? password.toCharArray() : new char[0]; + try { + PrivateKey privateKey = readPemPrivateKey(privateKeyFile.getBytes(), pw); + Certificate cert = + CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(certFile.getBytes())); + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry("alias", privateKey, pw, new Certificate[] {cert}); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + keyStore.store(out, pw); + return out.toByteArray(); + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + log.error("Failed to build keystore from PEM certificate", e); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Failed to read PEM certificate — check the key/certificate files and password"); + } + } + + /** Reads a PEM private key (PKCS8/PKCS1, optionally password-encrypted). */ + private PrivateKey readPemPrivateKey(byte[] pemBytes, char[] password) throws Exception { + try (PEMParser pemParser = + new PEMParser(new InputStreamReader(new ByteArrayInputStream(pemBytes)))) { + Object pemObject = pemParser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); + PrivateKeyInfo keyInfo; + if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) { + InputDecryptorProvider decryptor = + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); + keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); + } else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) { + PEMDecryptorProvider decryptor = + new JcePEMDecryptorProviderBuilder().build(password); + keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); + } else if (pemObject instanceof PEMKeyPair keyPair) { + keyInfo = keyPair.getPrivateKeyInfo(); + } else if (pemObject instanceof PrivateKeyInfo info) { + keyInfo = info; + } else { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); + } + return converter.getPrivateKey(keyInfo); + } + } + /** Helper class to wrap byte array as MultipartFile. */ private static class ByteArrayMultipartFile implements MultipartFile { private final byte[] content; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/MetadataEncryptionServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/MetadataEncryptionServiceTest.java index bd532ee780..197f83cd33 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/MetadataEncryptionServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/MetadataEncryptionServiceTest.java @@ -120,4 +120,39 @@ class MetadataEncryptionServiceTest { assertThatThrownBy(() -> noKeyService.encrypt("anything")) .isInstanceOf(IllegalStateException.class); } + + // ------------------------------------------------------------------------- + // Byte-array (keystore) round-trip + // ------------------------------------------------------------------------- + + @Test + void encryptBytes_null_returnsNull() { + assertThat(service.encryptBytes(null)).isNull(); + } + + @Test + void decryptBytes_null_returnsNull() { + assertThat(service.decryptBytes(null)).isNull(); + } + + @Test + void encryptBytes_producesEncPrefix() { + String encrypted = service.encryptBytes(new byte[] {1, 2, 3}); + assertThat(encrypted).startsWith(MetadataEncryptionService.ENC_PREFIX); + } + + @Test + void byteRoundTrip_restoresOriginalBytes() { + byte[] original = {0, 1, 2, (byte) 0xFF, 64, 65, 66}; + assertThat(service.decryptBytes(service.encryptBytes(original))).isEqualTo(original); + } + + @Test + void decryptBytes_legacyPlainBase64_stillDecodes() { + // Values written before keystore encryption was introduced are stored as plain Base64 + // (no enc: prefix) and must still decode. + byte[] original = {10, 20, 30}; + String legacy = java.util.Base64.getEncoder().encodeToString(original); + assertThat(service.decryptBytes(legacy)).isEqualTo(original); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceMoreTest.java index 2fd34a1e10..4d1c4b1e38 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceMoreTest.java @@ -79,6 +79,15 @@ class SigningFinalizationServiceMoreTest { metadataEncryptionService, serverCertificateService, userServerCertificateService); + // Keystores are stored encrypted at rest; these fixtures store them as plain Base64 (the + // legacy form), which decryptBytes decodes unchanged. + lenient() + .when(metadataEncryptionService.decryptBytes(any())) + .thenAnswer( + inv -> { + String stored = inv.getArgument(0, String.class); + return stored == null ? null : Base64.getDecoder().decode(stored); + }); } // ------------------------------------------------------------------------- diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java index eaba1a88ff..cf985d568e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java @@ -144,6 +144,76 @@ class WorkflowSessionServiceTest { assertThat(cert.get("password")).isNotEqualTo("secret"); } + @Test + void signDocument_encryptsUploadedKeystoreBytesAtRest() throws Exception { + // Use a REAL encryption service (not a mock) so we verify the persisted keystore is + // genuinely AES-256-GCM encrypted, not merely base64-encoded. + ApplicationProperties.AutomaticallyGenerated generated = + new ApplicationProperties.AutomaticallyGenerated(); + generated.setKey("test-encryption-key-for-unit-tests-only"); + ApplicationProperties realProps = new ApplicationProperties(); + realProps.setAutomaticallyGenerated(generated); + MetadataEncryptionService realEncryption = new MetadataEncryptionService(realProps); + + // Validator is exercised by the real flow but its result is irrelevant here, so stub it. + CertificateSubmissionValidator validator = mock(CertificateSubmissionValidator.class); + + WorkflowSessionService svc = + new WorkflowSessionService( + workflowSessionRepository, + workflowParticipantRepository, + storedFileRepository, + userRepository, + storageProvider, + objectMapper, + applicationProperties, + realEncryption, + validator); + + User user = user("dave"); + WorkflowParticipant participant = pendingParticipant(user); + sessionWithParticipant("s7", participant); + when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + byte[] p12Bytes; + try (var in = getClass().getResourceAsStream("/test-certs/valid-test.p12")) { + assertThat(in).as("valid-test.p12 fixture present").isNotNull(); + p12Bytes = in.readAllBytes(); + } + + SignDocumentRequest req = new SignDocumentRequest(); + req.setCertType("PKCS12"); + req.setPassword("changeit"); + req.setP12File( + new MockMultipartFile( + "p12File", "valid-test.p12", "application/x-pkcs12", p12Bytes)); + + svc.signDocument("s7", user, req); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(workflowParticipantRepository).save(captor.capture()); + + @SuppressWarnings("unchecked") + Map cert = + (Map) + captor.getValue().getParticipantMetadata().get("certificateSubmission"); + String storedKeystore = (String) cert.get("p12Keystore"); + + // 1. Stored keystore is encrypted (enc: prefix), not plaintext base64. + assertThat(storedKeystore).startsWith(MetadataEncryptionService.ENC_PREFIX); + assertThat(storedKeystore) + .as("must not be the plain base64 of the keystore") + .isNotEqualTo(java.util.Base64.getEncoder().encodeToString(p12Bytes)); + + // 2. The raw keystore bytes must not appear anywhere in the stored value. + assertThat(storedKeystore) + .doesNotContain(java.util.Base64.getEncoder().encodeToString(p12Bytes)); + + // 3. It round-trips back to the exact original keystore bytes. + assertThat(realEncryption.decryptBytes(storedKeystore)).isEqualTo(p12Bytes); + } + @Test void signDocument_preservesExistingParticipantMetadata() { User user = user("carol"); diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 67779cc07d..413927a6df 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -4116,7 +4116,7 @@ current = "Current" stepLabel = "Step {{number}}" [groupSigning.steps.configureDefaults] -continue = "Continue to Review" +continue = "Continue" invisible = "Signatures will be invisible (metadata only)" locationLabel = "Location:" preview = "Preview" @@ -4145,13 +4145,13 @@ visibility = "Visibility:" visible = "Visible on page {{page}}" [groupSigning.steps.selectDocument] -continue = "Continue to Participant Selection" +continue = "Continue" noFile = "Please select a single PDF file from your active files to create a signing session." selectedFile = "Selected document" title = "Select Document" [groupSigning.steps.selectParticipants] -continue = "Continue to Signature Settings" +continue = "Continue" count_one = "{{count}} participant selected" count_other = "{{count}} participants selected" label = "Select participants" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 670c315589..406524fede 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -1,4 +1,3 @@ -accessInvite = "Invite" alphabet = "Alphabet" apply = "Apply" applyAndContinue = "Save & Leave" @@ -2225,10 +2224,11 @@ location = "Location" logoTitle = "Logo" name = "Name" noLogo = "No Logo" -notified = "Pending" pageNumber = "Page Number" password = "Certificate Password" passwordOptional = "Leave empty if no password" +pemCertificateLabel = "Certificate (.pem / .crt)" +pemPrivateKeyLabel = "Private key (.pem / .key)" pending = "Pending" readyToFinalize = "Ready to finalize" reason = "Reason" @@ -2319,7 +2319,6 @@ addParticipants = "Add Participants" addParticipantsError = "Failed to add participants" backToList = "Back to Sessions" deleteConfirm = "Are you sure? This cannot be undone." -deleted = "Session deleted" deleteError = "Failed to delete session" deleteSession = "Delete Session" dueDate = "Due Date" @@ -2335,7 +2334,6 @@ removeParticipant = "Remove" removeParticipantError = "Failed to remove participant" selectUsers = "Select users..." sessionInfo = "Session Info" -workbenchTitle = "Session Management" [certSign.collab.sessionList] active = "Active" @@ -2395,11 +2393,7 @@ usePersonalCert = "Personal Certificate" usePersonalCertDesc = "Auto-generated for your account" useServerCert = "Organization Certificate" useServerCertDesc = "Shared organization certificate" -workbenchTitle = "Sign Request" - -[certSign.collab.signRequest.canvas] -colorPickerTitle = "Choose stroke color" -continue = "Continue" +useSignature = "Use signature" [certSign.collab.signRequest.certModal] certInvalid = "Certificate invalid: {{error}}" @@ -2423,8 +2417,8 @@ image = "Upload" text = "Type" [certSign.collab.signRequest.preview] +create = "Add signature" imageAlt = "Selected signature" -missing = "No preview" textFallback = "Signature" [certSign.collab.signRequest.saved] @@ -2462,15 +2456,6 @@ visibility = "Visibility:" visible = "Visible" yourSignatures = "Your Signatures ({{count}})" -[certSign.collab.signRequest.text] -colorLabel = "Color" -fontLabel = "Font" -fontSizeLabel = "Size" -fontSizePlaceholder = "16" -label = "Signature Text" -modalHint = "Enter your name, then click Continue to place it on the PDF." -placeholder = "Enter your name..." - [certSign.collab.userSelector] inviteUsers = "Add Users" loadError = "Failed to load users" @@ -2481,11 +2466,6 @@ placeholder = "Select users..." [certSign.error] failed = "An error occurred while processing signatures." -[certSign.mobile] -panelActions = "Actions" -panelDocument = "Document" -panelPeople = "People" - [certSign.sessions] deleted = "Session deleted" fetchFailed = "Failed to load session details" @@ -4140,7 +4120,7 @@ current = "Current" stepLabel = "Step {{number}}" [groupSigning.steps.configureDefaults] -continue = "Continue to Review" +continue = "Continue" invisible = "Signatures will be invisible (metadata only)" locationLabel = "Location:" preview = "Preview" @@ -4169,13 +4149,13 @@ visibility = "Visibility:" visible = "Visible on page {{page}}" [groupSigning.steps.selectDocument] -continue = "Continue to Participant Selection" +continue = "Continue" noFile = "Please select a single PDF file from your active files to create a signing session." selectedFile = "Selected document" title = "Select Document" [groupSigning.steps.selectParticipants] -continue = "Continue to Signature Settings" +continue = "Continue" count_one = "{{count}} participant selected" count_other = "{{count}} participants selected" label = "Select participants" @@ -4497,6 +4477,10 @@ desc = "Detect and split scanned photos into separate pages" tags = "detect,split,photos,auto detect,detect photos,split photos,separate photos,split scanned images,multiple photos,auto split,photo detection,image detection,scan separation" title = "Detect & Split Scanned Photos" +[home.sharedSign] +desc = "Request signatures from others and track signing sessions" +title = "Shared Signing" + [home.showJS] desc = "Searches and displays any JS injected into a PDF" tags = "javascript,code,script,show javascript,show JS,find javascript,detect javascript,view javascript,embedded scripts,malware,security,inspect,debug" @@ -6236,80 +6220,10 @@ description = "Username for SMTP authentication" label = "SMTP Username" [quickAccess] -access = "Access" -accessAddPerson = "Add another person" -accessBack = "Back" -accessCopyLink = "Copy link" -accessEmail = "Email Address" -accessEmailPlaceholder = "name@company.com" -accessFileLabel = "File" -accessGeneral = "General Access" -accessInviteTitle = "Invite People" -accessOwner = "Owner" -accessPanel = "Document access" -accessPeople = "People with access" -accessRemove = "Remove" -accessRestricted = "Restricted" -accessRestrictedHint = "Only people with access can open" -accessRole = "Role" -accessRoleCommenter = "Commenter" -accessRoleEditor = "Editor" -accessRoleViewer = "Viewer" -accessSelectedFile = "Selected file" -accessSendInvite = "Send Invite" -accessTitle = "Document Access" -accessYou = "You" -activeSessions = "Active Sessions" -activeTab = "Active" -activity = "Activity" allTools = "Tools" automate = "Automate" -back = "Back" -backToAllTools = "Back to all tools" -certSign = "Certificate Sign" -completedSessions = "Completed Sessions" -completedTab = "Completed" config = "Config" -createSession = "Create Signing Request" -dueDate = "Due date (optional)" files = "Files" -filterDeclined = "Declined" -filterMine = "Mine" -filterOverdue = "Overdue" -filterSigned = "Signed" -help = "Help" -newRequest = "New Request" -noActiveSessions = "No pending sign requests or active sessions" -noCompletedSessions = "No completed sessions" -noFile = "No file selected" -read = "Read" -reader = "Reader" -refresh = "Refresh" -requestSignatures = "Request Signatures" -searchDocuments = "Search documents…" -selectedFile = "Selected file" -selectSingleFileToRequest = "Select a single PDF file to request signatures" -selectUsers = "Select users to sign" -selectUsersPlaceholder = "Choose participants..." -sendingRequest = "Sending..." -settings = "Settings" -sign = "Sign" -signatureRequests = "Signature Requests" -signYourself = "Sign Yourself" -tours = "Tours" -wetSign = "Add Signature" - -[quickAccess.helpMenu] -adminTour = "Admin Tour" -adminTourDesc = "Explore admin settings & features" -toolsTour = "Tools Tour" -toolsTourDesc = "Learn what the tools can do" -whatsNewTour = "See what's new in V2" -whatsNewTourDesc = "Tour the updated layout" - -[quickAccess.toursTooltip] -admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour." -user = "Watch walkthroughs here: Tools tour and the New V2 layout tour." [read] tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" @@ -7163,6 +7077,28 @@ title = "Login Not Enabled" description = "Enter the full URL of your self-hosted Stirling PDF server" label = "Server URL" +[sharedSign] +backToSessions = "Back to sessions" +createdOn = "Created {{date}}" +disabledBody = "Collaborative signing isn't enabled on this server." +disabledTitle = "Not enabled" +due = "Due {{date}}" +filterDeclined = "Declined" +filterMine = "Mine" +filterOverdue = "Overdue" +filterSigned = "Signed" +fromOwner = "From {{owner}}" +newRequest = "Request signatures" +signedCount = "{{signed}}/{{total}} signed" + +[sharedSign.empty] +active = "No pending sign requests or active sessions" +completed = "No completed sessions" + +[sharedSign.tab] +active = "Active" +completed = "Completed" + [showJS] done = "JavaScript extracted" processing = "Extracting JavaScript..." @@ -7192,6 +7128,9 @@ personalSigs = "Personal Signatures" previous = "Previous page" redo = "Redo" save = "Save Signature" +sharedSigningOpen = "Open shared signing" +sharedSigningRequest = "Request signatures" +sharedSigningStepDesc = "Send this document to others to sign instead of signing it yourself." sharedSigs = "Shared Signatures" submit = "Sign Document" tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting" @@ -7631,7 +7570,6 @@ noLinks = "No active share links yet." noSharedUsers = "No users have access yet." openInApp = "Open in Stirling PDF" ownerLabel = "Owner" -ownerOnly = "Only the owner can manage sharing." ownerUnknown = "Unknown" removeLink = "Remove link" removeUser = "Remove" @@ -7642,7 +7580,12 @@ roleCommenter = "Commenter" roleEditor = "Editor" roleLabel = "Role" roleViewer = "Viewer" -selectSingleFile = "Select a single file to manage sharing." +saveAndShare = "Save to server & share" +saveFailed = "Failed to save the file to the server. Please try again." +saveFailedTitle = "Couldn't save to server" +saveFirstBody = "Sharing works on files saved to the server. We'll save it to your files, then continue to sharing." +saveFirstHeading = "Save this file to the server to share it" +saveFirstTitle = "Share file" sharedUsersTitle = "Shared users" shareHeading = "Shared file" sharingDisabled = "Sharing is disabled." @@ -8060,7 +8003,6 @@ nextPage = "Next Page" onlyPdfSupported = "This file format is not supported for preview." pageNavigation = "Page navigation" previousPage = "Previous Page" -resetZoom = "Reset zoom" saveChangesErrorBody = "The document could not be saved. Try again." saveChangesErrorTitle = "Could not save changes" singlePageView = "Single Page View" @@ -8516,6 +8458,7 @@ search = "Search PDF" selectAll = "Select All" selectByNumber = "Select by Page Numbers" selectLanguage = "Select language" +share = "Share" toggleAnnotations = "Toggle Annotations Visibility" toggleAttachments = "Toggle Attachments" toggleBookmarks = "Toggle Bookmarks" diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index e77ee18b26..e8aded59d2 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -15,6 +15,11 @@ "title": "Sign - Stirling PDF", "description": "Adds signature to PDF by drawing, text or image" }, + "sharedSign": { + "image": "/og_images/home.png", + "title": "Shared Signing - Stirling PDF", + "description": "Request signatures from others and track signing sessions" + }, "addText": { "image": "/og_images/add-text.png", "title": "Add Text - Stirling PDF", @@ -489,6 +494,7 @@ "byPath": { "/cert-sign": "certSign", "/sign": "sign", + "/shared-sign": "sharedSign", "/add-text": "addText", "/add-password": "addPassword", "/remove-password": "removePassword", diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index f8855e2ae9..ede9ed266a 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -20,6 +20,7 @@ import { import { WorkbenchBarProvider } from "@app/contexts/WorkbenchBarContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; +import { SigningOverlayProvider } from "@app/contexts/SigningOverlayContext"; import { AnnotationProvider } from "@app/contexts/AnnotationContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; @@ -143,21 +144,23 @@ export function AppProviders({ - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx index de6348f624..08808b5b61 100644 --- a/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -277,7 +277,7 @@ export const DrawingCanvas: React.FC = ({ opened={modalOpen} onClose={closeModal} title={t("sign.canvas.modalTitle", "Draw your signature")} - size="auto" + size="min(48rem, 92vw)" centered > diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 4817616259..a1b9c84984 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -10,6 +10,7 @@ import { import { isBaseWorkbench } from "@app/types/workbench"; import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useSigningOverlay } from "@app/contexts/SigningOverlayContext"; import { useCookieConsent } from "@app/hooks/useCookieConsent"; import styles from "@app/components/layout/Workbench.module.css"; @@ -56,6 +57,7 @@ export default function Workbench() { } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow(); + const { overlay: signingOverlay } = useSigningOverlay(); // Get navigation state - this is the source of truth const { selectedTool: selectedToolId } = useNavigationState(); @@ -115,6 +117,25 @@ export default function Workbench() { return ; } + // Shared Signing drives the main viewer from the sidebar (document + overlays + // via context), ahead of the empty-state landing page. + if (currentView === "viewer" && signingOverlay?.file) { + return ( + + ); + } + if (activeFiles.length === 0) { return ; } diff --git a/frontend/editor/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx b/frontend/editor/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx deleted file mode 100644 index aa6c76afe4..0000000000 --- a/frontend/editor/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Core stub for QuickAccessBar footer extensions - * Desktop build overrides this with actual credit counter implementation - */ - -interface QuickAccessBarFooterExtensionsProps { - className?: string; -} - -export function QuickAccessBarFooterExtensions( - _props: QuickAccessBarFooterExtensionsProps, -) { - return null; -} diff --git a/frontend/editor/src/core/components/shared/QuickAccessBar.tsx b/frontend/editor/src/core/components/shared/QuickAccessBar.tsx deleted file mode 100644 index db102527ff..0000000000 --- a/frontend/editor/src/core/components/shared/QuickAccessBar.tsx +++ /dev/null @@ -1,1424 +0,0 @@ -import React, { - useState, - useRef, - forwardRef, - useEffect, - useMemo, - useCallback, -} from "react"; -import { createPortal } from "react-dom"; -import { Stack, Divider, Menu, Indicator } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { useNavigate, useLocation } from "react-router-dom"; -import LocalIcon from "@app/components/shared/LocalIcon"; -import SignPopout, { - SIGN_REQUEST_WORKBENCH_TYPE, - SESSION_DETAIL_WORKBENCH_TYPE, -} from "@app/components/shared/signing/SignPopout"; -import { useFilesModalContext } from "@app/contexts/FilesModalContext"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { useFileSelection, useFileState } from "@app/contexts/file/fileHooks"; -import { - useNavigationState, - useNavigationActions, -} from "@app/contexts/NavigationContext"; -import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation"; -import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; -import { ButtonConfig } from "@app/types/sidebar"; -import "@app/components/shared/quickAccessBar/QuickAccessBar.css"; -import { Tooltip } from "@app/components/shared/Tooltip"; -import AllToolsNavButton from "@app/components/shared/AllToolsNavButton"; -import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton"; -import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; -import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; -import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; -import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; -import { requestStartTour } from "@app/constants/events"; -import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton"; -import { useToursTooltip } from "@app/components/shared/quickAccessBar/useToursTooltip"; -import ShareManagementModal from "@app/components/shared/ShareManagementModal"; -import apiClient from "@app/services/apiClient"; -import { absoluteWithBasePath } from "@app/constants/app"; -import { alert } from "@app/components/toast"; -import { uploadHistoryChain } from "@app/services/serverStorageUpload"; -import { fileStorage } from "@app/services/fileStorage"; -import { useFileActions } from "@app/contexts/FileContext"; -import type { FileId } from "@app/types/file"; -import type { StirlingFileStub } from "@app/types/fileContext"; -import type { SignRequestSummary } from "@app/types/signingSession"; - -import { - isNavButtonActive, - getNavButtonStyle, - getActiveNavButton, -} from "@app/components/shared/quickAccessBar/QuickAccessBar"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -import { QuickAccessBarFooterExtensions } from "@app/components/quickAccessBar/QuickAccessBarFooterExtensions"; -import { useConfigButtonIcon } from "@app/hooks/useConfigButtonIcon"; - -const QuickAccessBar = forwardRef((_, ref) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const location = useLocation(); - const { isFilesModalOpen } = useFilesModalContext(); - const { - handleReaderToggle, - handleToolSelect, - selectedToolKey, - leftPanelView, - toolRegistry, - readerMode, - resetTool, - toolAvailability, - } = useToolWorkflow(); - const { selectedFiles, selectedFileIds } = useFileSelection(); - const { state, selectors } = useFileState(); - const { actions } = useFileActions(); - const { hasUnsavedChanges, workbench: currentWorkbench } = - useNavigationState(); - const { actions: navigationActions } = useNavigationActions(); - const { getToolNavigation } = useSidebarNavigation(); - const { config } = useAppConfig(); - const licenseAlert = useLicenseAlert(); - const [configModalOpen, setConfigModalOpen] = useState(false); - const [activeButton, setActiveButton] = useState("tools"); - const [accessMenuOpen, setAccessMenuOpen] = useState(false); - const [accessInviteOpen, setAccessInviteOpen] = useState(false); - const [selectedAccessFileId, setSelectedAccessFileId] = useState< - string | null - >(null); - const [shareManageOpen, setShareManageOpen] = useState(false); - const scrollableRef = useRef(null); - const accessButtonRef = useRef(null); - const accessPopoverRef = useRef(null); - const [accessPopoverPosition, setAccessPopoverPosition] = useState({ - top: 160, - left: 84, - }); - const { sharingEnabled, shareLinksEnabled } = useSharingEnabled(); - const groupSigningEnabled = useGroupSigningEnabled(); - const isSignWorkbenchActive = - currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE || - currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE; - const [inviteRows, setInviteRows] = useState< - Array<{ - id: number; - email: string; - role: "editor" | "commenter" | "viewer"; - error?: string; - }> - >([{ id: Date.now(), email: "", role: "editor" }]); - const [isInviting, setIsInviting] = useState(false); - - // Sign button state - const [signMenuOpen, setSignMenuOpen] = useState(false); - const signButtonRef = useRef(null); - const [pendingSignCount, setPendingSignCount] = useState(0); - - // Silently fetch pending sign request count for badge (every 60s) - useEffect(() => { - if (!groupSigningEnabled) return; - const fetchCount = async () => { - try { - const response = await apiClient.get( - "/api/v1/security/cert-sign/sign-requests", - ); - const pending = response.data.filter( - (r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED", - ).length; - setPendingSignCount(pending); - } catch { - /* silent - avoid noisy background error toasts */ - } - }; - fetchCount(); - const interval = setInterval(fetchCount, 60000); - return () => clearInterval(interval); - }, [groupSigningEnabled]); - - // Refresh badge count when popout closes (user may have acted on a request) - useEffect(() => { - if (!signMenuOpen && groupSigningEnabled) { - const timeout = setTimeout(async () => { - try { - const response = await apiClient.get( - "/api/v1/security/cert-sign/sign-requests", - ); - const pending = response.data.filter( - (r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED", - ).length; - setPendingSignCount(pending); - } catch { - /* silent */ - } - }, 500); - return () => clearTimeout(timeout); - } - }, [signMenuOpen, groupSigningEnabled]); - - const configButtonIcon = useConfigButtonIcon(); - - const { - tooltipOpen, - manualCloseOnly, - showCloseButton, - toursMenuOpen, - setToursMenuOpen, - handleTooltipOpenChange, - } = useToursTooltip(); - - const isRTL = - typeof document !== "undefined" && document.documentElement.dir === "rtl"; - const hasSelectedFiles = selectedFiles.length > 0; - const selectedFileStubs = useMemo( - () => - selectedFileIds - .map((id) => selectors.getStirlingFileStub(id)) - .filter((x): x is StirlingFileStub => Boolean(x)), - [selectedFileIds, selectors, state.files.byId], - ); - const selectedAccessFileStub = - selectedFileStubs.find((file) => file.id === selectedAccessFileId) || - selectedFileStubs[0]; - useEffect(() => { - if (!hasSelectedFiles) { - setAccessMenuOpen(false); - setSelectedAccessFileId(null); - setAccessInviteOpen(false); - return; - } - if ( - !selectedAccessFileId || - !selectedFiles.some((file) => file.fileId === selectedAccessFileId) - ) { - setSelectedAccessFileId(selectedFiles[0]?.fileId ?? null); - } - }, [hasSelectedFiles, selectedAccessFileId, selectedFiles]); - - const resetInviteRows = useCallback(() => { - setInviteRows([{ id: Date.now(), email: "", role: "editor" }]); - }, []); - - useEffect(() => { - if (!accessMenuOpen) return; - setAccessInviteOpen(false); - setIsInviting(false); - resetInviteRows(); - const updatePosition = () => { - const anchor = accessButtonRef.current; - if (!anchor) return; - const rect = anchor.getBoundingClientRect(); - const left = isRTL ? Math.max(16, rect.left - 360) : rect.right + 12; - const top = Math.max(24, rect.top - 24); - setAccessPopoverPosition({ top, left }); - }; - updatePosition(); - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - }; - }, [accessMenuOpen, isRTL, resetInviteRows]); - - useEffect(() => { - if (!accessMenuOpen) return; - const handleOutside = (event: MouseEvent) => { - const target = event.target as Node; - if (accessPopoverRef.current?.contains(target)) return; - if (accessButtonRef.current?.contains(target)) return; - - // Check if click is inside a Mantine dropdown - const mantineDropdown = (target as Element).closest?.( - ".mantine-Combobox-dropdown, .mantine-Popover-dropdown", - ); - if (mantineDropdown) return; - - setAccessMenuOpen(false); - }; - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setAccessMenuOpen(false); - } - }; - document.addEventListener("mousedown", handleOutside); - document.addEventListener("keydown", handleEscape); - return () => { - document.removeEventListener("mousedown", handleOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [accessMenuOpen]); - - const shareBaseUrl = useMemo(() => { - const frontendUrl = (config?.frontendUrl || "").trim(); - if (frontendUrl) { - try { - const parsed = new URL(frontendUrl); - if (parsed.protocol === "http:" || parsed.protocol === "https:") { - const normalized = frontendUrl.endsWith("/") - ? frontendUrl.slice(0, -1) - : frontendUrl; - return `${normalized}/share/`; - } - } catch { - // invalid URL - fall through to default - } - } - return absoluteWithBasePath("/share/"); - }, [config?.frontendUrl]); - - const ensureStoredFile = useCallback( - async (fileStub: StirlingFileStub): Promise => { - const localUpdatedAt = fileStub.createdAt ?? fileStub.lastModified ?? 0; - const isUpToDate = - Boolean(fileStub.remoteStorageId) && - Boolean(fileStub.remoteStorageUpdatedAt) && - (fileStub.remoteStorageUpdatedAt as number) >= localUpdatedAt; - if (isUpToDate && fileStub.remoteStorageId) { - return fileStub.remoteStorageId as number; - } - const originalFileId = (fileStub.originalFileId || fileStub.id) as FileId; - const remoteId = fileStub.remoteStorageId as number | undefined; - const { - remoteId: storedId, - updatedAt, - chain, - } = await uploadHistoryChain(originalFileId, remoteId); - for (const stub of chain) { - actions.updateStirlingFileStub(stub.id, { - remoteStorageId: storedId, - remoteStorageUpdatedAt: updatedAt, - remoteOwnedByCurrentUser: true, - remoteSharedViaLink: false, - }); - await fileStorage.updateFileMetadata(stub.id, { - remoteStorageId: storedId, - remoteStorageUpdatedAt: updatedAt, - remoteOwnedByCurrentUser: true, - remoteSharedViaLink: false, - }); - } - return storedId; - }, - [actions], - ); - - const openShareManage = useCallback(async () => { - if (!sharingEnabled) { - alert({ - alertType: "warning", - title: t("storageShare.sharingDisabled", "Sharing is disabled."), - expandable: false, - durationMs: 2500, - }); - return; - } - if (selectedFileStubs.length > 1) { - alert({ - alertType: "warning", - title: t( - "storageShare.selectSingleFile", - "Select a single file to manage sharing.", - ), - expandable: false, - durationMs: 2500, - }); - return; - } - if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) { - alert({ - alertType: "warning", - title: t( - "storageShare.ownerOnly", - "Only the owner can manage sharing.", - ), - expandable: false, - durationMs: 2500, - }); - return; - } - try { - if (selectedAccessFileStub) { - await ensureStoredFile(selectedAccessFileStub); - } - setAccessMenuOpen(false); - setShareManageOpen(true); - } catch (error) { - console.error("Failed to upload file for sharing:", error); - alert({ - alertType: "warning", - title: t( - "storageUpload.failure", - "Upload failed. Please check your login and storage settings.", - ), - expandable: false, - durationMs: 3000, - }); - } - }, [ - ensureStoredFile, - selectedAccessFileStub, - selectedFileStubs.length, - sharingEnabled, - t, - ]); - - const handleInviteRowChange = useCallback( - ( - id: number, - updates: Partial<{ - email: string; - role: "editor" | "commenter" | "viewer"; - error?: string; - }>, - ) => { - setInviteRows((prev) => - prev.map((row) => { - if (row.id !== id) return row; - const nextError = Object.prototype.hasOwnProperty.call( - updates, - "error", - ) - ? updates.error - : row.error; - return { ...row, ...updates, error: nextError }; - }), - ); - }, - [], - ); - - const handleAddInviteRow = useCallback(() => { - setInviteRows((prev) => [ - ...prev, - { id: Date.now(), email: "", role: "editor" }, - ]); - }, []); - - const handleRemoveInviteRow = useCallback((id: number) => { - setInviteRows((prev) => - prev.length > 1 ? prev.filter((row) => row.id !== id) : prev, - ); - }, []); - - const handleSendInvites = useCallback(async () => { - if (!selectedAccessFileStub) return; - if (selectedAccessFileStub.remoteOwnedByCurrentUser === false) { - alert({ - alertType: "warning", - title: t( - "storageShare.ownerOnly", - "Only the owner can manage sharing.", - ), - expandable: false, - durationMs: 2500, - }); - return; - } - const nextRows = inviteRows.map((row) => { - const trimmed = row.email.trim(); - let error: string | undefined; - if (!trimmed) { - error = t( - "storageShare.invalidUsername", - "Enter a valid username or email address.", - ); - } - return { ...row, email: trimmed, error }; - }); - setInviteRows(nextRows); - if (nextRows.some((row) => row.error)) { - return; - } - setIsInviting(true); - try { - const storedId = await ensureStoredFile(selectedAccessFileStub); - for (const row of nextRows) { - await apiClient.post(`/api/v1/storage/files/${storedId}/shares/users`, { - username: row.email.trim(), - accessRole: row.role, - }); - } - alert({ - alertType: "success", - title: t("storageShare.userAdded", "User added to shared list."), - expandable: false, - durationMs: 2500, - }); - setAccessInviteOpen(false); - resetInviteRows(); - } catch (error) { - console.error("Failed to send invite:", error); - alert({ - alertType: "warning", - title: t( - "storageShare.userAddFailed", - "Unable to share with that user.", - ), - expandable: false, - durationMs: 3000, - }); - } finally { - setIsInviting(false); - } - }, [ - ensureStoredFile, - inviteRows, - resetInviteRows, - selectedAccessFileStub, - t, - ]); - - const handleCopyShareLink = async () => { - if (!selectedAccessFileStub) return; - if (!shareLinksEnabled) { - alert({ - alertType: "warning", - title: t("storageShare.linksDisabled", "Share links are disabled."), - expandable: false, - durationMs: 2500, - }); - return; - } - if (selectedFileStubs.length > 1) { - alert({ - alertType: "warning", - title: t( - "storageShare.selectSingleFile", - "Select a single file to copy a link.", - ), - expandable: false, - durationMs: 2500, - }); - return; - } - if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) { - alert({ - alertType: "warning", - title: t( - "storageShare.ownerOnly", - "Only the owner can manage sharing.", - ), - expandable: false, - durationMs: 2500, - }); - return; - } - if (!selectedAccessFileStub?.remoteStorageId) { - try { - await ensureStoredFile(selectedAccessFileStub); - } catch (error) { - console.error("Failed to upload file for sharing:", error); - alert({ - alertType: "warning", - title: t( - "storageUpload.failure", - "Upload failed. Please check your login and storage settings.", - ), - expandable: false, - durationMs: 3000, - }); - return; - } - } - try { - const storedId = await ensureStoredFile(selectedAccessFileStub); - const response = await apiClient.get<{ - shareLinks?: Array<{ token?: string }>; - }>(`/api/v1/storage/files/${storedId}`, { - suppressErrorToast: true, - }); - const links = response.data?.shareLinks ?? []; - let token = links[links.length - 1]?.token; - if (!token) { - const shareResponse = await apiClient.post( - `/api/v1/storage/files/${storedId}/shares/links`, - { - accessRole: "editor", - }, - ); - token = shareResponse.data?.token; - if (token) { - actions.updateStirlingFileStub(selectedAccessFileStub.id, { - remoteHasShareLinks: true, - }); - await fileStorage.updateFileMetadata(selectedAccessFileStub.id, { - remoteHasShareLinks: true, - }); - } - } - if (!token) { - alert({ - alertType: "warning", - title: t( - "storageShare.failure", - "Unable to generate a share link. Please try again.", - ), - expandable: false, - durationMs: 2500, - }); - return; - } - await navigator.clipboard.writeText(`${shareBaseUrl}${token}`); - alert({ - alertType: "success", - title: t("storageShare.copied", "Link copied to clipboard"), - expandable: false, - durationMs: 2000, - }); - } catch (error) { - console.error("Failed to copy share link:", error); - alert({ - alertType: "warning", - title: t("storageShare.copyFailed", "Copy failed"), - expandable: false, - durationMs: 2500, - }); - } - }; - - // Open modal if URL is at /settings/* - useEffect(() => { - const isSettings = location.pathname.startsWith("/settings"); - setConfigModalOpen(isSettings); - }, [location.pathname]); - - useEffect(() => { - const next = getActiveNavButton(selectedToolKey, readerMode); - setActiveButton(next); - }, [leftPanelView, selectedToolKey, toolRegistry, readerMode]); - - const handleFilesButtonClick = () => { - navigate("/files"); - }; - - // Helper function to render navigation buttons with URL support - const renderNavButton = ( - config: ButtonConfig, - index: number, - shouldGuardNavigation = false, - ) => { - const isActive = - !isSignWorkbenchActive && - isNavButtonActive( - config, - activeButton, - isFilesModalOpen, - configModalOpen, - selectedToolKey, - leftPanelView, - ); - - // Check if this button has URL navigation support - const navProps = - config.type === "navigation" && - (config.id === "read" || config.id === "automate") - ? getToolNavigation(config.id) - : null; - - const handleClick = (e?: React.MouseEvent) => { - // If there are unsaved changes and this button should guard navigation, show warning modal - if (shouldGuardNavigation && hasUnsavedChanges) { - e?.preventDefault(); - navigationActions.requestNavigation(() => { - config.onClick(); - }); - return; - } - if (navProps && e) { - handleUnlessSpecialClick(e, config.onClick); - } else { - config.onClick(); - } - }; - - const buttonStyle = isSignWorkbenchActive - ? { - backgroundColor: "var(--icon-inactive-bg)", - color: "var(--icon-inactive-color)", - border: "none", - borderRadius: "0.5rem", - } - : getNavButtonStyle( - config, - activeButton, - isFilesModalOpen, - configModalOpen, - selectedToolKey, - leftPanelView, - ); - - // Render navigation button with conditional URL support - return ( -
- -
- ); - }; - - const mainButtons: ButtonConfig[] = useMemo( - () => - [ - { - id: "read", - name: t("quickAccess.reader", "Reader"), - icon: ( - - ), - size: "md" as const, - isRound: false, - type: "navigation" as const, - onClick: () => { - setActiveButton("read"); - handleReaderToggle(); - }, - }, - { - id: "automate", - name: t("quickAccess.automate", "Automate"), - icon: ( - - ), - size: "md" as const, - isRound: false, - type: "navigation" as const, - onClick: () => { - setActiveButton("automate"); - // If already on automate tool, reset it directly - if (selectedToolKey === "automate") { - resetTool("automate"); - } else { - handleToolSelect("automate"); - } - }, - }, - ].filter((button) => { - // Filter out buttons for disabled tools - // 'read' is always available (viewer mode) - if (button.id === "read") return true; - // Check if tool is actually available (not just present in registry) - const availability = - toolAvailability[button.id as keyof typeof toolAvailability]; - return availability?.available !== false; - }), - [ - t, - setActiveButton, - handleReaderToggle, - selectedToolKey, - resetTool, - handleToolSelect, - toolAvailability, - ], - ); - - const middleButtons: ButtonConfig[] = [ - { - id: "files", - name: t("quickAccess.files", "Files"), - icon: ( - - ), - isRound: true, - size: "md", - type: "modal", - onClick: handleFilesButtonClick, - }, - ]; - //TODO: Activity - //{ - // id: 'activity', - // name: t("quickAccess.activity", "Activity"), - // icon: , - // isRound: true, - // size: 'lg', - // type: 'navigation', - // onClick: () => setActiveButton('activity') - //}, - - // Determine if settings button should be hidden - // Hide when login is disabled AND showSettingsWhenNoLogin is false - const shouldHideSettingsButton = - config?.enableLogin === false && config?.showSettingsWhenNoLogin === false; - - const bottomButtons: ButtonConfig[] = [ - { - id: "help", - name: t("quickAccess.tours", "Tours"), - icon: ( - - ), - isRound: true, - size: "md", - type: "action", - onClick: () => { - // This will be overridden by the wrapper logic - }, - }, - ...(shouldHideSettingsButton - ? [] - : [ - { - id: "config", - name: t("quickAccess.settings", "Settings"), - icon: configButtonIcon ?? ( - - ), - size: "md" as const, - type: "modal" as const, - onClick: () => { - navigate("/settings/overview"); - setConfigModalOpen(true); - }, - } as ButtonConfig, - ]), - ]; - - return ( -
- {/* Fixed header outside scrollable area */} -
- - -
- - {/* Scrollable content area */} -
{ - // Prevent the wheel event from bubbling up to parent containers - e.stopPropagation(); - }} - > -
- {/* Main navigation section */} - - {mainButtons.map((config, index) => ( - - {renderNavButton( - config, - index, - config.id === "read" || config.id === "automate", - )} - - ))} - - - {/* Middle section */} - {middleButtons.length > 0 && ( - <> - - - {middleButtons.map((config, index) => ( - - {renderNavButton(config, index)} - - ))} - {hasSelectedFiles && sharingEnabled && ( -
- - } - label={t("quickAccess.access", "Access")} - isActive={!isSignWorkbenchActive && accessMenuOpen} - onClick={() => { - setAccessMenuOpen((prev) => !prev); - }} - ariaLabel={t("quickAccess.access", "Access")} - dataTestId="access-button" - /> -
- )} - {groupSigningEnabled && ( -
- {pendingSignCount > 0 ? ( - - - } - label={t("quickAccess.sign", "Sign")} - isActive={signMenuOpen || isSignWorkbenchActive} - onClick={() => setSignMenuOpen((prev) => !prev)} - ariaLabel={t("quickAccess.sign", "Sign")} - dataTestId="sign-button" - /> - - ) : ( - - } - label={t("quickAccess.sign", "Sign")} - isActive={signMenuOpen || isSignWorkbenchActive} - onClick={() => setSignMenuOpen((prev) => !prev)} - ariaLabel={t("quickAccess.sign", "Sign")} - dataTestId="sign-button" - /> - )} -
- )} -
- - )} - - {/* Spacer to push bottom buttons to bottom */} -
- - - - {/* Bottom section */} - - {bottomButtons.map((buttonConfig, index) => { - // Handle help button with menu or direct action - if (buttonConfig.id === "help") { - const isAdmin = config?.isAdmin === true; - const toursTooltipContent = isAdmin - ? t( - "quickAccess.toursTooltip.admin", - "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.", - ) - : t( - "quickAccess.toursTooltip.user", - "Watch walkthroughs here: Tools tour and the New V2 layout tour.", - ); - const tourItems = [ - { - key: "whatsnew", - icon: ( - - ), - title: t( - "quickAccess.helpMenu.whatsNewTour", - "See what's new in V2", - ), - description: t( - "quickAccess.helpMenu.whatsNewTourDesc", - "Tour the updated layout", - ), - onClick: () => requestStartTour("whatsnew"), - }, - { - key: "tools", - icon: ( - - ), - title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"), - description: t( - "quickAccess.helpMenu.toolsTourDesc", - "Learn what the tools can do", - ), - onClick: () => requestStartTour("tools"), - }, - ...(isAdmin - ? [ - { - key: "admin", - icon: ( - - ), - title: t( - "quickAccess.helpMenu.adminTour", - "Admin Tour", - ), - description: t( - "quickAccess.helpMenu.adminTourDesc", - "Explore admin settings & features", - ), - onClick: () => requestStartTour("admin"), - }, - ] - : []), - ]; - - const helpButtonNode = ( -
- - -
{renderNavButton(buttonConfig, index)}
-
- - {tourItems.map((item) => ( - -
-
- {item.title} -
-
- {item.description} -
-
-
- ))} -
-
-
- ); - - return ( - - - {helpButtonNode} - - - ); - } - - const buttonNode = renderNavButton(buttonConfig, index); - const shouldShowSettingsBadge = - buttonConfig.id === "config" && - licenseAlert.active && - licenseAlert.audience === "admin"; - - return ( - - {shouldShowSettingsBadge ? ( - - {buttonNode} - - ) : ( - buttonNode - )} - - ); - })} -
-
-
- - setConfigModalOpen(false)} - /> - - {selectedAccessFileStub && ( - setShareManageOpen(false)} - file={selectedAccessFileStub} - /> - )} - {hasSelectedFiles && - typeof document !== "undefined" && - createPortal( -
-
-
- -
- {accessInviteOpen - ? t("quickAccess.accessInviteTitle", "Invite People") - : t("quickAccess.accessTitle", "Document Access")} -
-
- {!accessInviteOpen && ( - - )} - -
-
- -
-
-
-
- {t("quickAccess.accessFileLabel", "File")} -
- -
- -
- -
-
- {t("quickAccess.accessGeneral", "General Access")} -
-
-
- -
-
-
- {t("quickAccess.accessRestricted", "Restricted")} -
-
- {t( - "quickAccess.accessRestrictedHint", - "Only people with access can open", - )} -
-
-
-
- -
- -
-
- {t("quickAccess.accessPeople", "People with access")} -
-
-
- {(selectedAccessFileStub?.remoteOwnerUsername || "You") - .slice(0, 2) - .toUpperCase()} -
-
-
- {selectedAccessFileStub?.remoteOwnerUsername || - t("quickAccess.accessYou", "You")} -
-
- {selectedAccessFileStub?.name ?? - t( - "quickAccess.accessSelectedFile", - "Selected file", - )} -
-
- - {t("quickAccess.accessOwner", "Owner")} - -
-
-
- -
-
-
- {t("quickAccess.accessInviteTitle", "Invite People")} -
-
- {inviteRows.map((row) => ( -
-
- - - handleInviteRowChange(row.id, { - email: event.target.value, - error: undefined, - }) - } - /> - {row.error && ( -
- {row.error} -
- )} -
-
- - -
- -
- ))} - -
-
- -
- {accessInviteOpen ? ( - <> - - {shareLinksEnabled && ( - - )} - - ) : ( - <> - {sharingEnabled && ( - - )} - {shareLinksEnabled && ( - - )} - - )} -
-
-
, - document.body, - )} - - {/* Sign Popover */} - setSignMenuOpen(false)} - buttonRef={signButtonRef} - isRTL={isRTL} - groupSigningEnabled={groupSigningEnabled} - /> -
- ); -}); - -QuickAccessBar.displayName = "QuickAccessBar"; - -export default QuickAccessBar; diff --git a/frontend/editor/src/core/components/shared/ShareManagementModal.tsx b/frontend/editor/src/core/components/shared/ShareManagementModal.tsx index 48a8b1d7d4..18c54ce342 100644 --- a/frontend/editor/src/core/components/shared/ShareManagementModal.tsx +++ b/frontend/editor/src/core/components/shared/ShareManagementModal.tsx @@ -17,6 +17,7 @@ import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded"; import DeleteIcon from "@mui/icons-material/Delete"; import HistoryIcon from "@mui/icons-material/History"; import LinkIcon from "@mui/icons-material/Link"; +import ShareIcon from "@mui/icons-material/Share"; import { useTranslation } from "react-i18next"; import apiClient from "@app/services/apiClient"; @@ -472,13 +473,25 @@ const ShareManagementModal: React.FC = ({ opened={opened} onClose={onClose} centered - title={t("storageShare.manageTitle", "Manage Sharing")} + title={ + + + + {t("storageShare.manageTitle", "Manage Sharing")} + + + } zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} - size="xl" + size={shareLinksEnabled ? "min(58rem, 94vw)" : "min(46rem, 94vw)"} + radius="lg" + padding="lg" + styles={{ + header: { paddingTop: "0.875rem", paddingBottom: "0.875rem" }, + }} overlayProps={{ blur: 8 }} > - + {t( "storageShare.manageDescription", @@ -491,7 +504,7 @@ const ShareManagementModal: React.FC = ({ {file.name} - + {errorMessage && ( = ({ )} - + {shareLinksEnabled && ( @@ -574,8 +590,9 @@ const ShareManagementModal: React.FC = ({ {t("storageShare.sharedUsersTitle", "Shared users")} - + + {/* Share (viewer only; opens the same modal as My Files "Manage sharing") */} + {currentView === "viewer" && sharingEnabled && ( + + )} + {/* Print */} {currentView === "viewer" && renderWithTooltip( diff --git a/frontend/editor/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/editor/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx deleted file mode 100644 index 36d70638cf..0000000000 --- a/frontend/editor/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** - * ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar - * - * When a user selects a tool from the Tools list, this component displays the tool's - * icon and name at the top of the navigation bar. It provides a quick way to see which - * tool is currently active and offers a back button to return to the Tools list. - * - * Features: - * - Shows tool icon and name when a tool is selected - * - Hover to reveal back arrow for returning to Tools - * - Smooth slide-down/slide-up animations - * - Only appears for tools that don't have dedicated nav buttons (read, sign, automate) - */ - -import React, { useEffect, useRef, useState } from "react"; -import { ActionIcon, Divider } from "@mantine/core"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import { useTranslation } from "react-i18next"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { - useNavigationState, - useNavigationActions, -} from "@app/contexts/NavigationContext"; -import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation"; -import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; -import FitText from "@app/components/shared/FitText"; -import { Tooltip } from "@app/components/shared/Tooltip"; - -interface ActiveToolButtonProps { - activeButton: string; - setActiveButton: (id: string) => void; - tooltipPosition?: "left" | "right" | "top" | "bottom"; -} - -const NAV_IDS = ["read", "sign", "automate"]; - -const ActiveToolButton: React.FC = ({ - setActiveButton, - tooltipPosition = "right", -}) => { - const { t } = useTranslation(); - const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = - useToolWorkflow(); - const { hasUnsavedChanges } = useNavigationState(); - const { actions: navigationActions } = useNavigationActions(); - const { getHomeNavigation } = useSidebarNavigation(); - - // Determine if the indicator should be visible (do not require selectedTool to be resolved yet) - // Special case: multiTool should always show even when sidebars are hidden - const indicatorShouldShow = Boolean( - selectedToolKey && - ((leftPanelView === "toolContent" && !NAV_IDS.includes(selectedToolKey)) || - selectedToolKey === "multiTool"), - ); - - // Local animation and hover state - const [indicatorTool, setIndicatorTool] = useState< - typeof selectedTool | null - >(null); - const [indicatorVisible, setIndicatorVisible] = useState(false); - const [replayAnim, setReplayAnim] = useState(false); - const [isBackHover, setIsBackHover] = useState(false); - const prevKeyRef = useRef(null); - const collapseTimeoutRef = useRef(null); - const animTimeoutRef = useRef(null); - const replayRafRef = useRef(null); - - const isSwitchingToNewTool = () => { - return prevKeyRef.current && prevKeyRef.current !== selectedToolKey; - }; - - const clearTimers = () => { - if (collapseTimeoutRef.current) { - window.clearTimeout(collapseTimeoutRef.current); - collapseTimeoutRef.current = null; - } - if (animTimeoutRef.current) { - window.clearTimeout(animTimeoutRef.current); - animTimeoutRef.current = null; - } - }; - - const playGrowDown = () => { - clearTimers(); - setIndicatorTool(selectedTool); - setIndicatorVisible(true); - // Force a replay even if the class is already applied - setReplayAnim(false); - if (replayRafRef.current) { - cancelAnimationFrame(replayRafRef.current); - replayRafRef.current = null; - } - replayRafRef.current = requestAnimationFrame(() => { - setReplayAnim(true); - }); - prevKeyRef.current = (selectedToolKey as string) || null; - animTimeoutRef.current = window.setTimeout(() => { - setReplayAnim(false); - animTimeoutRef.current = null; - }, 500); - }; - - const firstShow = () => { - clearTimers(); - setIndicatorTool(selectedTool); - setIndicatorVisible(true); - prevKeyRef.current = (selectedToolKey as string) || null; - animTimeoutRef.current = window.setTimeout(() => { - animTimeoutRef.current = null; - }, 500); - }; - - const triggerCollapse = () => { - clearTimers(); - setIndicatorVisible(false); - collapseTimeoutRef.current = window.setTimeout(() => { - setIndicatorTool(null); - prevKeyRef.current = null; - collapseTimeoutRef.current = null; - }, 500); // match CSS transition duration - }; - - useEffect(() => { - if (indicatorShouldShow) { - clearTimers(); - if (!indicatorVisible) { - firstShow(); - return; - } - if (!indicatorTool) { - firstShow(); - } else if (isSwitchingToNewTool()) { - playGrowDown(); - } else { - // keep reference in sync - prevKeyRef.current = (selectedToolKey as string) || null; - } - } else if (indicatorTool || indicatorVisible) { - triggerCollapse(); - } - }, [indicatorShouldShow, selectedTool, selectedToolKey]); - - useEffect(() => { - return () => { - clearTimers(); - if (replayRafRef.current) { - cancelAnimationFrame(replayRafRef.current); - replayRafRef.current = null; - } - }; - }, []); - - return ( - <> -
- {indicatorTool && ( -
-
- - { - const performNavigation = () => { - setActiveButton("tools"); - handleBackToTools(); - }; - if (hasUnsavedChanges) { - e.preventDefault(); - navigationActions.requestNavigation(performNavigation); - return; - } - handleUnlessSpecialClick(e, performNavigation); - }} - size={"lg"} - variant="subtle" - onMouseEnter={() => setIsBackHover(true)} - onMouseLeave={() => setIsBackHover(false)} - aria-label={ - isBackHover - ? t("quickAccess.backToAllTools", "Back to all tools") - : indicatorTool.name - } - style={{ - backgroundColor: isBackHover - ? "var(--color-gray-300)" - : "var(--icon-tools-bg)", - color: isBackHover ? "#fff" : "var(--icon-tools-color)", - border: "none", - borderRadius: "8px", - cursor: "pointer", - textDecoration: "none", - }} - > - - {isBackHover ? ( - - ) : ( - indicatorTool.icon - )} - - - - -
- -
- )} -
- - ); -}; - -export default ActiveToolButton; diff --git a/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessBar.css b/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessBar.css deleted file mode 100644 index d5f9592bfd..0000000000 --- a/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessBar.css +++ /dev/null @@ -1,990 +0,0 @@ -.activeIconScale { - transform: scale(1); - transition: transform 0.2s; - z-index: 1; - width: calc(1.75rem + 10px) !important; - height: calc(1.75rem + 10px) !important; -} - -.activeIconScale .iconContainer { - width: calc(1.5rem + 10px); - height: calc(1.5rem + 10px); -} - -.activeIconScale .iconContainer svg, -.activeIconScale .iconContainer img { - width: calc(1.25rem + 10px) !important; - height: calc(1.25rem + 10px) !important; -} - -.iconContainer { - display: flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - transition: - width 0.2s, - height 0.2s; -} - -/* Action icon styles */ -.action-icon-style { - background-color: var(--icon-user-bg); - color: var(--icon-user-color); - border-radius: 50%; - width: 1.5rem; - height: 1.5rem; -} - -/* Main container styles */ -.quick-access-bar-main { - background-color: var(--bg-muted); - width: 4.5rem; - min-width: 4.5rem; - max-width: 4.5rem; - position: relative; - z-index: 10; - border-right: 1px solid var(--border-default); - flex-shrink: 0; - box-sizing: border-box; - direction: ltr; /* keep layout stable when document is rtl */ -} - -/* RTL adjustments keep the bar on-screen and separated from content */ -:root[dir="rtl"] .quick-access-bar-main { - border-right: none; - border-left: 1px solid var(--border-default); -} - -/* Header padding */ -.quick-access-header { - padding: 1rem 0.25rem 0.5rem 0.25rem; -} - -.nav-header { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - margin-bottom: 0; - gap: 0.5rem; -} - -/* All tools text styles */ -.all-tools-text { - margin-top: 0.75rem; - font-size: 0.75rem; - text-rendering: optimizeLegibility; - font-synthesis: none; - text-align: center; - display: block; -} - -.all-tools-text.active { - color: var(--text-primary); - font-weight: bold; -} - -.all-tools-text.inactive { - color: var(--color-gray-700); - font-weight: normal; -} - -/* Overflow divider */ -.overflow-divider { - width: 3rem; - border-color: var(--color-gray-300); - margin: 0 auto; - align-self: center; -} - -/* Scrollable content area */ -.quick-access-bar { - overflow-x: hidden; - overflow-y: hidden; - padding: 0 0.25rem 1rem 0.25rem; -} - -/* Scrollable content container */ -.scrollable-content { - display: flex; - flex-direction: column; - height: 100%; - min-height: 100%; -} - -/* Button text styles */ -.button-text { - margin-top: 0.75rem; - font-size: 0.75rem; - text-rendering: optimizeLegibility; - font-synthesis: none; - text-align: center; - width: 100%; -} - -/* Allow wrapping under the active top indicator; constrain to three lines */ -.current-tool-label { - white-space: normal; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 3; /* show up to three lines */ - line-clamp: 3; - -webkit-box-orient: vertical; - word-break: break-all; - width: 100%; - box-sizing: border-box; - text-align: center; -} - -.button-text.active { - color: var(--text-primary); - font-weight: bold; -} - -.button-text.inactive { - color: var(--color-gray-700); - font-weight: normal; -} - -/* Content divider */ -.content-divider { - width: 3rem; - border-color: var(--color-gray-300); - margin: 1rem auto; - align-self: center; -} - -/* Spacer */ -.spacer { - flex: 1; - min-height: 1rem; -} - -/* Config button text */ -.config-button-text { - margin-top: 0.75rem; - font-size: 0.75rem; - color: var(--color-gray-700); - font-weight: normal; - text-rendering: optimizeLegibility; - font-synthesis: none; -} - -/* Font size utility */ -.font-size-20 { - font-size: 20px; -} - -/* Hide scrollbar by default, show on scroll (Webkit browsers - Chrome, Safari, Edge) */ -.quick-access-bar::-webkit-scrollbar { - width: 0.5rem; - height: 0.5rem; - background: transparent; -} - -.quick-access-bar:hover::-webkit-scrollbar, -.quick-access-bar:active::-webkit-scrollbar, -.quick-access-bar:focus::-webkit-scrollbar { - background: rgba(0, 0, 0, 0.1); -} - -.quick-access-bar::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); - border-radius: 0.25rem; -} - -.quick-access-bar::-webkit-scrollbar-track { - background: transparent; -} - -/* Firefox scrollbar styling */ -.quick-access-bar { - scrollbar-width: auto; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; -} - -/* Animated current tool indicator that slides in from the top and pushes content down */ -/* Container grows down so it pushes items below during animation */ -.current-tool-slot { - overflow: hidden; - max-height: 0; - opacity: 0; - transition: - max-height 450ms ease-out, - opacity 300ms ease-out; -} - -.current-tool-enter { - animation: currentToolGrowDown 450ms ease-out; -} - -.current-tool-slot.visible { - max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */ - opacity: 1; - margin-bottom: 1rem; -} - -/* Replay the grow-down animation when switching tools while visible */ -.current-tool-slot.replay .current-tool-content { - animation: currentToolGrowDown 450ms ease-out; -} - -/* Also animate the container itself when replaying so it "pushes down" again */ -.current-tool-slot.replay { - animation: currentToolGrowDown 450ms ease-out; -} - -@keyframes currentToolGrowDown { - 0% { - max-height: 0; - opacity: 0; - } - 100% { - max-height: 7.875rem; /* enough space for icon + up to 3-line label (126px) */ - opacity: 1; - } -} - -/* Divider under active tool indicator */ -.current-tool-divider { - width: 3rem; - border-color: var(--color-gray-300); - margin: 0.75rem auto 0; -} - -/* Access popout */ -.quick-access-popout { - position: fixed; - z-index: 1000; - width: 21rem; - max-height: calc(100vh - 4rem); - opacity: 0; - pointer-events: none; - transform: translateX(-8px) scale(0.98); - transition: - opacity 160ms ease, - transform 200ms ease; -} - -.quick-access-popout.is-open { - opacity: 1; - pointer-events: auto; - transform: translateX(0) scale(1); -} - -.quick-access-popout__card { - background: var(--bg-raised); - border-radius: 16px; - border: 1px solid var(--border-default); - box-shadow: 0 18px 36px - color-mix(in srgb, var(--text-primary) 12%, transparent); - overflow: hidden; - display: flex; - flex-direction: column; - max-height: calc(100vh - 4rem); -} - -.quick-access-popout__header { - background: color-mix(in srgb, var(--text-primary) 18%, var(--bg-toolbar)); - color: var(--text-primary); - display: grid; - grid-template-columns: 2.25rem 1fr 2.25rem; - align-items: center; - padding: 0.75rem 0.75rem; - flex-shrink: 0; -} - -[data-mantine-color-scheme="light"] .quick-access-popout__header { - background: #3c4c6f; - color: #fff; -} - -.quick-access-popout__title { - text-align: center; - font-weight: 600; - font-size: 0.95rem; - letter-spacing: 0.01em; -} - -.quick-access-popout__header-action { - display: flex; - align-items: center; - justify-content: center; - opacity: 0.9; - background: transparent; - border: none; - color: inherit; - width: 2rem; - height: 2rem; - border-radius: 10px; - cursor: pointer; -} - -.quick-access-popout__header-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.25rem; -} - -.quick-access-popout__back { - background: transparent; - border: none; - color: inherit; - width: 2rem; - height: 2rem; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: opacity 160ms ease; -} - -.quick-access-popout__back.is-visible { - opacity: 1; - pointer-events: auto; -} - -.quick-access-popout__body { - display: flex; - width: 200%; - transform: translateX(0%); - transition: transform 240ms ease; -} - -.quick-access-popout__body.is-invite { - transform: translateX(-50%); -} - -/* Tab-based positioning for 3 panels */ -.quick-access-popout__body.tab-access, -.quick-access-popout__body.tab-requestSignatures, -.quick-access-popout__body.tab-signRequests { - width: 300%; -} - -.quick-access-popout__body.tab-access { - transform: translateX(0%); -} - -.quick-access-popout__body.tab-requestSignatures { - transform: translateX(-33.333%); -} - -.quick-access-popout__body.tab-signRequests { - transform: translateX(-66.666%); -} - -.quick-access-popout__panel { - width: 50%; - padding: 1rem 1rem 0.75rem; - box-sizing: border-box; - flex-shrink: 0; - display: inline-block; - vertical-align: top; -} - -/* 3-panel layout for tab system */ -.quick-access-popout__body.tab-access .quick-access-popout__panel, -.quick-access-popout__body.tab-requestSignatures .quick-access-popout__panel, -.quick-access-popout__body.tab-signRequests .quick-access-popout__panel { - width: 33.333%; -} - -.quick-access-popout__panel--invite { - background: var(--bg-surface); -} - -.quick-access-popout__section { - margin-bottom: 0.85rem; -} - -.quick-access-popout__label { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-muted); - margin-bottom: 0.35rem; -} - -.quick-access-popout__select, -.quick-access-popout__input { - width: 100%; - border: 1px solid var(--border-default); - border-radius: 10px; - padding: 0.5rem 0.75rem; - font-size: 0.9rem; - background: var(--bg-surface); - color: var(--text-primary); -} - -.quick-access-popout__input-group { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.2rem; -} - -.quick-access-popout__input.has-error { - border-color: var(--color-yellow-400); - background: color-mix( - in srgb, - var(--color-yellow-200) 18%, - var(--bg-surface) - ); -} - -.quick-access-popout__input::placeholder { - color: var(--text-muted); -} - -.quick-access-popout__invite-row { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.quick-access-popout__remove { - width: 1.75rem; - height: 1.75rem; - border-radius: 8px; - border: 1px solid var(--border-default); - background: var(--bg-surface); - color: var(--text-muted); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.quick-access-popout__input-error { - font-size: 0.7rem; - color: var(--color-red-500); -} - -.quick-access-popout__divider { - height: 1px; - background: var(--border-default); - margin: 0.75rem 0; -} - -.quick-access-popout__row { - display: flex; - gap: 0.75rem; - align-items: center; -} - -.quick-access-popout__row-text { - flex: 1; - min-width: 0; - overflow: hidden; -} - -.quick-access-popout__icon-bubble { - width: 2rem; - height: 2rem; - border-radius: 50%; - background: color-mix(in srgb, var(--btn-open-file) 18%, var(--bg-surface)); - color: var(--btn-open-file); - display: flex; - align-items: center; - justify-content: center; -} - -.quick-access-popout__row-title { - font-size: 0.85rem; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.quick-access-popout__row-subtitle { - font-size: 0.75rem; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.quick-access-popout__person { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.quick-access-popout__avatar { - width: 2.2rem; - height: 2.2rem; - border-radius: 50%; - background: color-mix(in srgb, var(--btn-open-file) 20%, var(--bg-surface)); - color: var(--btn-open-file); - font-weight: 700; - font-size: 0.8rem; - display: flex; - align-items: center; - justify-content: center; -} - -.quick-access-popout__person-text { - flex: 1; -} - -.quick-access-popout__pill { - background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-muted)); - color: var(--text-secondary); - font-size: 0.7rem; - font-weight: 600; - padding: 0.2rem 0.6rem; - border-radius: 999px; -} - -.quick-access-popout__add { - display: flex; - align-items: center; - gap: 0.5rem; - border: none; - background: transparent; - color: var(--btn-open-file); - font-size: 0.85rem; - font-weight: 600; - padding: 0.25rem 0; - cursor: pointer; -} - -.quick-access-popout__add[disabled] { - opacity: 0.4; - cursor: default; -} - -.quick-access-popout__add-icon { - width: 1.25rem; - height: 1.25rem; - border-radius: 50%; - border: 2px solid currentColor; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.9rem; -} - -.quick-access-popout__hint { - font-size: 0.75rem; - color: #6b7280; - margin-top: 0.35rem; -} - -.quick-access-popout__warning { - margin-top: 0.75rem; - background: color-mix( - in srgb, - var(--color-yellow-100) 35%, - var(--bg-surface) - ); - border: 1px solid var(--color-yellow-300); - border-radius: 12px; - padding: 0.75rem; - color: var(--color-yellow-700); - font-size: 0.8rem; -} - -.quick-access-popout__warning-title { - font-weight: 600; - margin-bottom: 0.25rem; -} - -.quick-access-popout__warning-body { - margin-bottom: 0.4rem; -} - -.quick-access-popout__warning-list { - font-size: 0.75rem; - color: var(--color-yellow-700); - margin-bottom: 0.5rem; -} - -.quick-access-popout__warning-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; -} - -.quick-access-popout__secondary { - background: var(--bg-surface); - border: 1px solid var(--color-yellow-300); - border-radius: 8px; - padding: 0.35rem 0.75rem; - color: var(--color-yellow-700); - font-weight: 600; - cursor: pointer; -} - -.quick-access-popout__footer { - display: flex; - gap: 0.75rem; - padding: 0.75rem 1rem 1rem; - background: var(--bg-muted); - border-top: 1px solid var(--border-default); - flex-shrink: 0; -} - -.quick-access-popout__error { - font-size: 0.75rem; - color: var(--color-red-500); - margin-top: 0.35rem; -} - -.quick-access-popout__primary { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - background: var(--btn-open-file); - color: #fff; - border: none; - border-radius: 10px; - padding: 0.5rem 0.75rem; - font-weight: 600; - cursor: pointer; -} - -.quick-access-popout__primary:disabled { - opacity: 0.6; - cursor: default; -} - -.quick-access-popout__link { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - background: var(--bg-surface); - color: var(--btn-open-file); - border: 1px solid var(--border-default); - border-radius: 10px; - padding: 0.5rem 0.75rem; - font-weight: 600; - cursor: pointer; -} - -/* Quick sign shortcuts */ -.quick-access-popout__quick-sign { - padding: 0.75rem 1rem 0; - flex-shrink: 0; -} - -.quick-access-popout__section-label { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-tertiary); - margin-bottom: 0.5rem; -} - -.quick-access-popout__quick-sign-actions { - display: flex; - gap: 0.5rem; - margin-bottom: 0.75rem; -} - -.quick-access-popout__quick-sign-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - padding: 0.5rem 0.6rem; - background: var(--bg-muted); - border: 1px solid var(--border-default); - border-radius: 8px; - cursor: pointer; - font-size: 0.78rem; - font-weight: 500; - color: var(--text-primary); - transition: - background 120ms, - border-color 120ms; - white-space: nowrap; -} - -.quick-access-popout__quick-sign-btn:hover { - background: color-mix(in srgb, var(--btn-open-file) 12%, var(--bg-muted)); - border-color: color-mix( - in srgb, - var(--btn-open-file) 40%, - var(--border-default) - ); -} - -/* Tab navigation */ -.quick-access-popout__tab-nav { - display: flex; - gap: 0.5rem; - padding: 0 1rem; - border-bottom: 1px solid var(--border-default); - margin-bottom: 0.75rem; - flex-shrink: 0; -} - -.quick-access-popout__tab-button { - padding: 0.5rem 0.75rem; - background: transparent; - border: none; - cursor: pointer; - font-size: 0.85rem; - color: var(--text-muted); - border-bottom: 2px solid transparent; - transition: - color 150ms, - border-color 150ms; -} - -.quick-access-popout__tab-button.active { - color: var(--text-primary); - border-bottom-color: var(--btn-open-file); - font-weight: 500; -} - -.quick-access-popout__tab-button:hover { - color: var(--text-primary); -} - -/* Sign request row styling */ -.quick-access-popout__sign-request-row { - padding: 0.75rem; - border-radius: 8px; - background: var(--bg-surface); - margin-bottom: 0.5rem; - cursor: pointer; - transition: background 150ms; - display: flex; - justify-content: space-between; - align-items: center; -} - -.quick-access-popout__sign-request-row:hover { - background: color-mix(in srgb, var(--text-primary) 5%, var(--bg-surface)); -} - -.quick-access-popout__sign-request-info { - flex: 1; - min-width: 0; -} - -.quick-access-popout__sign-request-badge { - flex-shrink: 0; - margin-left: 0.5rem; -} - -/* Sign popover height constraints */ -.quick-access-sign-popout { - width: 26rem; -} - -.quick-access-sign-popout .quick-access-popout__card { - display: flex; - flex-direction: column; - overflow: hidden; -} - -.quick-access-popout__section-label--padded { - padding: 0.5rem 1rem 0; -} - -.quick-access-popout__section-label--row { - display: flex; - align-items: center; - justify-content: space-between; -} - -.quick-access-popout__section-action { - display: flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - padding: 0; - background: color-mix( - in srgb, - var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, - transparent - ); - border: 1px solid - color-mix( - in srgb, - var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 40%, - transparent - ); - border-radius: 5px; - color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); - cursor: pointer; - transition: - background 120ms, - border-color 120ms, - color 120ms; - flex-shrink: 0; -} - -.quick-access-popout__section-action:hover { - background: color-mix( - in srgb, - var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 28%, - transparent - ); - border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); -} - -.quick-access-sign-popout .quick-access-popout__header { - flex-shrink: 0; -} - -.quick-access-sign-popout .quick-access-popout__tab-nav { - flex-shrink: 0; -} - -.quick-access-sign-popout .quick-access-popout__quick-sign { - border-bottom: 1px solid var(--border-default); - padding-bottom: 0.75rem; - margin-bottom: 0; -} - -.quick-access-sign-popout .quick-access-popout__quick-sign-actions { - margin-bottom: 0; -} - -.quick-access-sign-popout .quick-access-popout__footer { - flex-shrink: 0; -} - -/* Search + filter bar */ -.quick-access-popout__search-filter { - padding: 0 0.75rem 0.5rem; - flex-shrink: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.quick-access-popout__search { - width: 100%; - padding: 0.375rem 0.625rem; - font-size: 0.8rem; - border: 1px solid var(--border-default); - border-radius: 6px; - background: var(--bg-surface); - color: var(--text-primary); - outline: none; - box-sizing: border-box; -} - -.quick-access-popout__search:focus { - border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); -} - -.quick-access-popout__search::placeholder { - color: var(--text-muted); -} - -.quick-access-popout__filter-chips { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -.quick-access-popout__filter-chip { - padding: 0.2rem 0.625rem; - font-size: 0.75rem; - border: 1px solid var(--border-default); - border-radius: 999px; - background: transparent; - color: var(--text-muted); - cursor: pointer; - transition: - background 120ms, - border-color 120ms, - color 120ms; - white-space: nowrap; -} - -.quick-access-popout__filter-chip:hover { - border-color: var(--text-muted); - color: var(--text-primary); -} - -.quick-access-popout__filter-chip.is-active { - background: color-mix( - in srgb, - var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, - transparent - ); - border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); - color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); - font-weight: 500; -} - -/* Sign popover 3-panel layout */ -.quick-access-sign-popout .quick-access-popout__body { - width: 300%; - transform: translateX(0%); - transition: transform 240ms ease; - overflow-y: auto; - overflow-x: hidden; - flex: 1; - min-height: 0; -} - -.quick-access-sign-popout - .quick-access-popout__body.sign-tab-requestSignatures { - transform: translateX(0%); -} - -.quick-access-sign-popout .quick-access-popout__body.sign-tab-signRequests { - transform: translateX(-33.333%); -} - -.quick-access-sign-popout .quick-access-popout__body.sign-tab-mySessions { - transform: translateX(-66.666%); -} - -.quick-access-sign-popout .quick-access-popout__panel { - width: 33.333%; - overflow-y: auto; - display: flex; - flex-direction: column; - max-height: 100%; -} - -@media (max-width: 980px) { - .quick-access-popout { - left: 5.25rem !important; - right: 1rem; - width: auto; - } -} - -/* Phone: Drawer takes over SignPopout entirely. - This is a defensive fallback in case the portal branch ever renders on phone. */ -@media (max-width: 768px) { - .quick-access-sign-popout { - left: 0 !important; - right: 0 !important; - top: 0 !important; - width: 100vw !important; - height: 100vh !important; - border-radius: 0; - } - - .quick-access-sign-popout .quick-access-popout__card { - border-radius: 0; - max-height: 100vh; - height: 100vh; - } -} diff --git a/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessBar.ts b/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessBar.ts deleted file mode 100644 index 1d38c26eb0..0000000000 --- a/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessBar.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ButtonConfig } from "@app/types/sidebar"; - -// Border radius constants -export const ROUND_BORDER_RADIUS = "0.5rem"; - -/** - * Check if a navigation button is currently active - */ -export const isNavButtonActive = ( - config: ButtonConfig, - activeButton: string, - isFilesModalOpen: boolean, - configModalOpen: boolean, - selectedToolKey?: string | null, - leftPanelView?: "toolPicker" | "toolContent" | "hidden", -): boolean => { - const isActiveByLocalState = - config.type === "navigation" && activeButton === config.id; - const isActiveByContext = - config.type === "navigation" && - leftPanelView === "toolContent" && - selectedToolKey === config.id; - const isActiveByModal = - (config.type === "modal" && config.id === "files" && isFilesModalOpen) || - (config.type === "modal" && config.id === "config" && configModalOpen); - - return isActiveByLocalState || isActiveByContext || isActiveByModal; -}; - -/** - * Get button styles based on active state - */ -export const getNavButtonStyle = ( - config: ButtonConfig, - activeButton: string, - isFilesModalOpen: boolean, - configModalOpen: boolean, - selectedToolKey?: string | null, - leftPanelView?: "toolPicker" | "toolContent" | "hidden", -) => { - const isActive = isNavButtonActive( - config, - activeButton, - isFilesModalOpen, - configModalOpen, - selectedToolKey, - leftPanelView, - ); - - if (isActive) { - return { - backgroundColor: `var(--icon-${config.id}-bg)`, - color: `var(--icon-${config.id}-color)`, - border: "none", - borderRadius: ROUND_BORDER_RADIUS, - }; - } - - // Inactive state for all buttons - return { - backgroundColor: "var(--icon-inactive-bg)", - color: "var(--icon-inactive-color)", - border: "none", - borderRadius: ROUND_BORDER_RADIUS, - }; -}; - -/** - * Determine the active nav button based on current tool state and registry - */ -export const getActiveNavButton = ( - selectedToolKey: string | null, - readerMode: boolean, -): string => { - // Reader mode takes precedence and should highlight the Read nav item - if (readerMode) { - return "read"; - } - // If a tool is selected, highlight it immediately even if the panel view - // transition to 'toolContent' has not completed yet. This prevents a brief - // period of no-highlight during rapid navigation. - return selectedToolKey ? selectedToolKey : "tools"; -}; diff --git a/frontend/editor/src/core/components/shared/quickAccessBar/useToursTooltip.ts b/frontend/editor/src/core/components/shared/quickAccessBar/useToursTooltip.ts deleted file mode 100644 index 8d4d7ee0d5..0000000000 --- a/frontend/editor/src/core/components/shared/quickAccessBar/useToursTooltip.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { TOUR_STATE_EVENT, type TourStatePayload } from "@app/constants/events"; -import { - isOnboardingCompleted, - hasShownToursTooltip, - markToursTooltipShown, -} from "@app/components/onboarding/orchestrator/onboardingStorage"; - -export interface ToursTooltipState { - tooltipOpen: boolean | undefined; - manualCloseOnly: boolean; - showCloseButton: boolean; - toursMenuOpen: boolean; - setToursMenuOpen: (open: boolean) => void; - handleTooltipOpenChange: (next: boolean) => void; -} - -/** - * Encapsulates all the logic for the tours tooltip: - * - Shows automatically after onboarding/tour completes (once per user) - * - Hides while the tours menu is open - * - After dismissal, reverts to hover-only tooltip - */ -export function useToursTooltip(): ToursTooltipState { - const [showToursTooltip, setShowToursTooltip] = useState(false); - const [toursMenuOpen, setToursMenuOpen] = useState(false); - const tourWasOpenRef = useRef(false); - - // Auto-show when a tour ends (fires once per user) - useEffect(() => { - if (typeof window === "undefined") return; - - const handleTourStateChange = (event: Event) => { - const { detail } = event as CustomEvent; - const wasOpen = tourWasOpenRef.current; - tourWasOpenRef.current = detail.isOpen; - - if (wasOpen && !detail.isOpen && !hasShownToursTooltip()) { - setShowToursTooltip(true); - } - }; - - window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange); - return () => - window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange); - }, []); - - // Show once after onboarding is complete - useEffect(() => { - if (isOnboardingCompleted() && !hasShownToursTooltip()) { - setShowToursTooltip(true); - } - }, []); - - const handleDismissToursTooltip = useCallback(() => { - markToursTooltipShown(); - setShowToursTooltip(false); - }, []); - - const hasBeenDismissed = hasShownToursTooltip(); - - const handleTooltipOpenChange = useCallback( - (next: boolean) => { - if (!next) { - if (!hasBeenDismissed) { - handleDismissToursTooltip(); - } - } else if (!hasBeenDismissed && !toursMenuOpen) { - setShowToursTooltip(true); - } - }, - [hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip], - ); - - const tooltipOpen = toursMenuOpen - ? false - : hasBeenDismissed - ? undefined - : showToursTooltip; - - return { - tooltipOpen, - manualCloseOnly: !hasBeenDismissed, - showCloseButton: !hasBeenDismissed, - toursMenuOpen, - setToursMenuOpen, - handleTooltipOpenChange, - }; -} diff --git a/frontend/editor/src/core/components/shared/signing/ActiveSessionsPanel.tsx b/frontend/editor/src/core/components/shared/signing/ActiveSessionsPanel.tsx deleted file mode 100644 index d0d2ed75a4..0000000000 --- a/frontend/editor/src/core/components/shared/signing/ActiveSessionsPanel.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Loader, Center, Text, Badge } from "@mantine/core"; - -interface SessionItem { - itemType: "signRequest" | "mySession"; - sessionId: string; - documentName: string; - createdAt: string; - myStatus?: string; - ownerUsername?: string; - ownerEmail?: string; - dueDate?: string; - finalized?: boolean; - signedCount?: number; - participantCount?: number; -} - -interface ActiveSessionsPanelProps { - sessions: SessionItem[]; - loading: boolean; - onSessionClick: (session: SessionItem) => void; -} - -const ActiveSessionsPanel = ({ - sessions, - loading, - onSessionClick, -}: ActiveSessionsPanelProps) => { - const { t } = useTranslation(); - - const getStatusColor = ( - status?: string, - itemType?: string, - item?: SessionItem, - ): string => { - if (itemType === "mySession" && item) { - const signedCount = item.signedCount ?? 0; - const totalCount = item.participantCount ?? 0; - - if (signedCount === totalCount && totalCount > 0) { - return "green"; // All signed - } - if (signedCount > 0) { - return "yellow"; // Partial - } - return "blue"; // None signed - } - switch (status) { - case "VIEWED": - return "blue"; - case "NOTIFIED": - case "PENDING": - return "orange"; - default: - return "gray"; - } - }; - - const getStatusLabel = (item: SessionItem): string => { - if (item.itemType === "mySession") { - const signedCount = item.signedCount ?? 0; - const totalCount = item.participantCount ?? 0; - if (signedCount === totalCount && totalCount > 0) { - return t("certSign.readyToFinalize", "Ready to finalize"); - } - // Show progress for all cases (including 0/X) - if (totalCount > 0) { - return t( - "certSign.signatureProgress", - "{{signedCount}}/{{totalCount}} signatures", - { signedCount, totalCount }, - ); - } - return t("certSign.awaitingSignatures", "Awaiting signatures"); - } - - // For sign requests - switch (item.myStatus) { - case "VIEWED": - return t("certSign.viewed", "Viewed"); - case "NOTIFIED": - return t("certSign.notified", "Pending"); - case "PENDING": - return t("certSign.pending", "Pending"); - default: - return item.myStatus || "PENDING"; - } - }; - - return ( -
- {loading ? ( -
- -
- ) : ( - <> - {sessions.length === 0 ? ( -
- - {t( - "quickAccess.noActiveSessions", - "No pending sign requests or active sessions", - )} - -
- ) : ( - <> - {sessions.map((session) => ( -
onSessionClick(session)} - > -
-
- {session.documentName} -
-
- {session.itemType === "signRequest" ? ( - <> - From: {session.ownerUsername} - {session.dueDate && - ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`} - - ) : ( - <> - Created:{" "} - {new Date(session.createdAt).toLocaleDateString()} - {session.signedCount !== undefined && - session.participantCount !== undefined && - ` • ${session.signedCount}/${session.participantCount} signed`} - - )} -
-
-
- - {getStatusLabel(session)} - -
-
- ))} - - )} - - )} -
- ); -}; - -export default ActiveSessionsPanel; diff --git a/frontend/editor/src/core/components/shared/signing/CompletedSessionsPanel.tsx b/frontend/editor/src/core/components/shared/signing/CompletedSessionsPanel.tsx deleted file mode 100644 index 02036277ae..0000000000 --- a/frontend/editor/src/core/components/shared/signing/CompletedSessionsPanel.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Loader, Center, Text, Badge } from "@mantine/core"; - -interface SessionItem { - itemType: "signRequest" | "mySession"; - sessionId: string; - documentName: string; - createdAt: string; - myStatus?: string; - ownerUsername?: string; - ownerEmail?: string; - dueDate?: string; - finalized?: boolean; - signedCount?: number; - participantCount?: number; -} - -interface CompletedSessionsPanelProps { - sessions: SessionItem[]; - loading: boolean; - onSessionClick: (session: SessionItem) => void; -} - -const CompletedSessionsPanel = ({ - sessions, - loading, - onSessionClick, -}: CompletedSessionsPanelProps) => { - const { t } = useTranslation(); - - const getStatusColor = (status?: string, itemType?: string): string => { - if (itemType === "mySession") return "green"; - switch (status) { - case "SIGNED": - return "green"; - case "DECLINED": - return "red"; - default: - return "gray"; - } - }; - - const getStatusLabel = (item: SessionItem): string => { - if (item.itemType === "mySession") { - return t("certSign.finalized", "Finalized"); - } - - // For sign requests - switch (item.myStatus) { - case "SIGNED": - return t("certSign.signed", "Signed"); - case "DECLINED": - return t("certSign.declined", "Declined"); - default: - return item.myStatus || "COMPLETED"; - } - }; - - return ( -
- {loading ? ( -
- -
- ) : sessions.length === 0 ? ( -
- - {t("quickAccess.noCompletedSessions", "No completed sessions")} - -
- ) : ( - <> - {sessions.map((session) => ( -
onSessionClick(session)} - > -
-
- {session.documentName} -
-
- {session.itemType === "signRequest" ? ( - <> - From: {session.ownerUsername} - {session.dueDate && - ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`} - - ) : ( - <> - Created:{" "} - {new Date(session.createdAt).toLocaleDateString()} - {session.signedCount !== undefined && - session.participantCount !== undefined && - ` • ${session.signedCount}/${session.participantCount} signed`} - - )} -
-
-
- - {getStatusLabel(session)} - -
-
- ))} - - )} -
- ); -}; - -export default CompletedSessionsPanel; diff --git a/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.tsx b/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.tsx index a2042ea01d..fde16ff093 100644 --- a/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.tsx +++ b/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.tsx @@ -47,9 +47,9 @@ const StepWrapper: React.FC = ({ : "1px solid var(--mantine-color-default-border)", borderRadius: "var(--mantine-radius-default)", backgroundColor: isActive - ? "var(--mantine-color-blue-0)" + ? "var(--mantine-color-blue-light)" : isCompleted - ? "var(--mantine-color-gray-0)" + ? "var(--mantine-color-gray-light)" : "transparent", opacity: !isActive && !isCompleted ? 0.6 : 1, }} diff --git a/frontend/editor/src/core/components/shared/signing/CreateSessionPanel.tsx b/frontend/editor/src/core/components/shared/signing/CreateSessionPanel.tsx deleted file mode 100644 index dfbb1c0f74..0000000000 --- a/frontend/editor/src/core/components/shared/signing/CreateSessionPanel.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Text, Switch } from "@mantine/core"; -import UserSelector from "@app/components/shared/UserSelector"; -import type { FileState } from "@app/types/file"; - -interface CreateSessionPanelProps { - selectedFiles: FileState[]; - selectedUserIds: number[]; - onSelectedUserIdsChange: (userIds: number[]) => void; - dueDate: string; - onDueDateChange: (date: string) => void; - creating: boolean; - includeSummaryPage: boolean; - onIncludeSummaryPageChange: (value: boolean) => void; -} - -const CreateSessionPanel = ({ - selectedFiles, - selectedUserIds, - onSelectedUserIdsChange, - dueDate, - onDueDateChange, - creating, - includeSummaryPage, - onIncludeSummaryPageChange, -}: CreateSessionPanelProps) => { - const { t } = useTranslation(); - - const hasValidFile = selectedFiles.length === 1; - - return ( -
- {!hasValidFile ? ( -
- - {t( - "quickAccess.selectSingleFileToRequest", - "Select a single PDF file to request signatures", - )} - -
- ) : ( - <> -
-
- {t("quickAccess.selectedFile", "File")} -
-
- {selectedFiles[0]?.name || - t("quickAccess.noFile", "No file loaded")} -
-
- -
-
- {t("quickAccess.selectUsers", "Select users to sign")} -
- -
- -
-
- {t("quickAccess.dueDate", "Due date (optional)")} -
- onDueDateChange(e.target.value)} - disabled={creating} - /> -
- -
- - onIncludeSummaryPageChange(e.currentTarget.checked) - } - disabled={creating} - size="sm" - /> -
- - )} -
- ); -}; - -export default CreateSessionPanel; diff --git a/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.tsx b/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.tsx new file mode 100644 index 0000000000..c8415b3127 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.tsx @@ -0,0 +1,53 @@ +import { Badge, Button, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import GroupAddOutlinedIcon from "@mui/icons-material/GroupAddOutlined"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { useSigningSessions } from "@app/hooks/signing/useSigningSessions"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; + +/** + * Content for the optional "Request signatures" step in the Sign tool: a short + * explanation plus a link to the standalone Shared Signing tool. Renders + * nothing unless group signing is enabled on the server. + */ +export default function SharedSigningLauncher() { + const { t } = useTranslation(); + const groupSigningEnabled = useGroupSigningEnabled(); + const { handleToolSelect } = useToolWorkflow(); + + // Surfaces the count of sign requests awaiting this user's action. + const { signRequests } = useSigningSessions({ + enabled: groupSigningEnabled, + }); + const pendingCount = signRequests.filter( + (req) => req.myStatus !== "SIGNED" && req.myStatus !== "DECLINED", + ).length; + + if (!groupSigningEnabled) return null; + + return ( + + + {t( + "sign.sharedSigningStepDesc", + "Send this document to others to sign instead of signing it yourself.", + )} + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx deleted file mode 100644 index a904c87246..0000000000 --- a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx +++ /dev/null @@ -1,1030 +0,0 @@ -import { lazy, useState, useEffect, useCallback, useRef } from "react"; -import { createPortal } from "react-dom"; -import { useTranslation } from "react-i18next"; -import { Drawer } from "@mantine/core"; -import { useIsPhone } from "@app/hooks/useIsMobile"; -import LocalIcon from "@app/components/shared/LocalIcon"; -import ActiveSessionsPanel from "@app/components/shared/signing/ActiveSessionsPanel"; -import CompletedSessionsPanel from "@app/components/shared/signing/CompletedSessionsPanel"; -import CreateSessionPanel from "@app/components/shared/signing/CreateSessionPanel"; -import apiClient from "@app/services/apiClient"; -import { alert } from "@app/components/toast"; -import { - SignRequestSummary, - SignRequestDetail, - SessionSummary, - SessionDetail, -} from "@app/types/signingSession"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { - useNavigationActions, - useNavigationState, -} from "@app/contexts/NavigationContext"; -import { useFileSelection } from "@app/contexts/file/fileHooks"; -import { fileStorage } from "@app/services/fileStorage"; -import { useFileActions } from "@app/contexts/FileContext"; -// These workbench views pull in the PDF viewer / pdfium / @embedpdf chain, so -// they are loaded on demand when the certSign collab feature actually opens -// one of them. Workbench wraps custom views in . -const SignRequestWorkbenchView = lazy( - () => import("@app/components/tools/certSign/SignRequestWorkbenchView"), -); -const SessionDetailWorkbenchView = lazy( - () => import("@app/components/tools/certSign/SessionDetailWorkbenchView"), -); -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; - -export const SIGN_REQUEST_WORKBENCH_TYPE = - "custom:signRequestWorkbench" as const; -export const SESSION_DETAIL_WORKBENCH_TYPE = - "custom:sessionDetailWorkbench" as const; - -type SessionItem = (SignRequestSummary | SessionSummary) & { - itemType: "signRequest" | "mySession"; -}; - -function sortSessions( - sessions: SessionItem[], - tab: "active" | "completed", -): SessionItem[] { - return [...sessions].sort((a, b) => { - if (tab === "active") { - const aDue = (a as SignRequestSummary).dueDate; - const bDue = (b as SignRequestSummary).dueDate; - if (aDue && bDue) - return new Date(aDue).getTime() - new Date(bDue).getTime(); - if (aDue) return -1; - if (bDue) return 1; - } - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }); -} - -interface SignPopoutProps { - isOpen: boolean; - onClose: () => void; - buttonRef: React.RefObject; - isRTL: boolean; - groupSigningEnabled: boolean; -} - -const SignPopout = ({ - isOpen, - onClose, - buttonRef, - isRTL, - groupSigningEnabled, -}: SignPopoutProps) => { - const { t } = useTranslation(); - const isPhone = useIsPhone(); - const popoverRef = useRef(null); - const [popoverPosition, setPopoverPosition] = useState({ - top: 160, - left: 84, - }); - const [maxHeight, setMaxHeight] = useState(undefined); - - // Tab state - const [activeTab, setActiveTab] = useState<"active" | "completed">("active"); - const [showCreatePanel, setShowCreatePanel] = useState(false); - - // Search / filter state - const [searchQuery, setSearchQuery] = useState(""); - const [activeFilters, setActiveFilters] = useState>(new Set()); - - const handleTabChange = (tab: "active" | "completed") => { - setActiveTab(tab); - setSearchQuery(""); - setActiveFilters(new Set()); - }; - - const toggleFilter = (key: string) => { - setActiveFilters((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }; - - // Data state - const [signRequests, setSignRequests] = useState([]); - const [mySessions, setMySessions] = useState([]); - const [loading, setLoading] = useState(false); - - // Create form state - const [selectedUserIds, setSelectedUserIds] = useState([]); - const [dueDate, setDueDate] = useState(""); - const [creating, setCreating] = useState(false); - const [includeSummaryPage, setIncludeSummaryPage] = useState(false); - - // Hooks - const { selectedFiles } = useFileSelection(); - const { actions: fileActions } = useFileActions(); - const { actions: navigationActions } = useNavigationActions(); - const { workbench: currentView } = useNavigationState(); - const { - registerCustomWorkbenchView, - unregisterCustomWorkbenchView, - setCustomWorkbenchViewData, - clearCustomWorkbenchViewData, - handleToolSelect, - } = useToolWorkflow(); - - // Workbench IDs - const SIGN_REQUEST_WORKBENCH_ID = "signRequestWorkbench"; - const SESSION_DETAIL_WORKBENCH_ID = "sessionDetailWorkbench"; - - // Register workbenches when group signing is enabled. - // No cleanup on unmount — registration must persist when this component unmounts - // on mobile (QuickAccessBar is desktop-only). Re-registering on remount is idempotent. - useEffect(() => { - if (!groupSigningEnabled) return; - - registerCustomWorkbenchView({ - id: SIGN_REQUEST_WORKBENCH_ID, - workbenchId: SIGN_REQUEST_WORKBENCH_TYPE, - label: t("certSign.collab.signRequest.workbenchTitle", "Sign Request"), - component: SignRequestWorkbenchView, - hideTopControls: true, - hideToolPanel: true, - }); - - registerCustomWorkbenchView({ - id: SESSION_DETAIL_WORKBENCH_ID, - workbenchId: SESSION_DETAIL_WORKBENCH_TYPE, - label: t( - "certSign.collab.sessionDetail.workbenchTitle", - "Session Management", - ), - component: SessionDetailWorkbenchView, - hideTopControls: true, - hideToolPanel: true, - }); - }, [groupSigningEnabled]); - - // Unregister workbenches only when the feature is explicitly disabled - useEffect(() => { - if (groupSigningEnabled) return; - unregisterCustomWorkbenchView(SIGN_REQUEST_WORKBENCH_ID); - unregisterCustomWorkbenchView(SESSION_DETAIL_WORKBENCH_ID); - }, [groupSigningEnabled, unregisterCustomWorkbenchView]); - - // Clear sign request workbench data when the user navigates away from it - useEffect(() => { - if (currentView !== SIGN_REQUEST_WORKBENCH_TYPE) { - clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); - } - }, [currentView]); - - // Clear session detail workbench data when the user navigates away from it - useEffect(() => { - if (currentView !== SESSION_DETAIL_WORKBENCH_TYPE) { - clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); - } - }, [currentView]); - - // Position popover (desktop/tablet only — phone uses Drawer) - useEffect(() => { - if (!isOpen || isPhone) return; - - const updatePosition = () => { - const anchor = buttonRef.current; - if (!anchor) return; - const rect = anchor.getBoundingClientRect(); - const left = isRTL ? Math.max(16, rect.left - 360) : rect.right + 12; - const viewportHeight = window.innerHeight; - - // Start at button position with small offset - let top = rect.top - 24; - - // Ensure minimum top margin - top = Math.max(24, top); - - // Calculate available height from top position to bottom of viewport - const availableHeight = viewportHeight - top - 24; // 24px bottom margin - - setPopoverPosition({ top, left }); - setMaxHeight(availableHeight); - }; - - updatePosition(); - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, { capture: true }); - - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, { capture: true }); - }; - }, [isOpen, isRTL, buttonRef]); - - // Handle outside clicks (desktop/tablet only — Drawer handles its own backdrop on phone) - useEffect(() => { - if (!isOpen || isPhone) return; - - const handleOutside = (event: MouseEvent) => { - const target = event.target as Node; - if (popoverRef.current?.contains(target)) return; - if (buttonRef.current?.contains(target)) return; - - const mantineDropdown = (target as Element).closest?.( - ".mantine-Combobox-dropdown, .mantine-Popover-dropdown", - ); - if (mantineDropdown) return; - - onClose(); - }; - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - - document.addEventListener("mousedown", handleOutside); - document.addEventListener("keydown", handleEscape); - - return () => { - document.removeEventListener("mousedown", handleOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [isOpen, onClose, buttonRef]); - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [requestsResponse, sessionsResponse] = await Promise.all([ - apiClient.get( - "/api/v1/security/cert-sign/sign-requests", - ), - apiClient.get("/api/v1/security/cert-sign/sessions"), - ]); - setSignRequests(requestsResponse.data); - setMySessions(sessionsResponse.data); - } catch (error) { - console.error( - "Failed to fetch signing data:", - error instanceof Error ? error.message : error, - ); - alert({ - alertType: "warning", - title: t("common.error"), - body: t("certSign.fetchFailed", "Failed to load signing data"), - expandable: false, - durationMs: 2500, - }); - } finally { - setLoading(false); - } - }, [t]); - - // Fetch data when opened (only needed for group signing sessions) - useEffect(() => { - if (isOpen && groupSigningEnabled) { - fetchData(); - } - }, [isOpen, groupSigningEnabled, fetchData]); - - // Auto-refresh Active tab every 15 seconds to show updated signature status - useEffect(() => { - if ( - isOpen && - groupSigningEnabled && - activeTab === "active" && - !showCreatePanel - ) { - const interval = setInterval(() => { - fetchData(); - }, 15000); // Refresh every 15 seconds - - return () => clearInterval(interval); - } - }, [isOpen, activeTab, showCreatePanel, fetchData]); - - // Combine and filter sessions - const activeSessions: SessionItem[] = [ - // Sign requests where user hasn't signed or declined yet - ...signRequests - .filter((req) => req.myStatus !== "SIGNED" && req.myStatus !== "DECLINED") - .map((req) => ({ ...req, itemType: "signRequest" as const })), - // Sessions user created that aren't finalized yet - ...mySessions - .filter((s) => !s.finalized) - .map((s) => ({ ...s, itemType: "mySession" as const })), - ]; - - const completedSessions: SessionItem[] = [ - // Sign requests where user has signed or declined - ...signRequests - .filter((req) => req.myStatus === "SIGNED" || req.myStatus === "DECLINED") - .map((req) => ({ ...req, itemType: "signRequest" as const })), - // Sessions user created that have been finalized - ...mySessions - .filter((s) => s.finalized) - .map((s) => ({ ...s, itemType: "mySession" as const })), - ]; - - // Filter options vary by tab - const filterOptions = - activeTab === "active" - ? [ - { key: "mine", label: t("quickAccess.filterMine", "Mine") }, - { key: "overdue", label: t("quickAccess.filterOverdue", "Overdue") }, - ] - : [ - { key: "mine", label: t("quickAccess.filterMine", "Mine") }, - { key: "signed", label: t("quickAccess.filterSigned", "Signed") }, - { - key: "declined", - label: t("quickAccess.filterDeclined", "Declined"), - }, - ]; - - const applyFiltersAndSearch = (sessions: SessionItem[]): SessionItem[] => { - let result = sessions; - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - result = result.filter((s) => s.documentName.toLowerCase().includes(q)); - } - const now = new Date(); - if (activeFilters.has("mine")) - result = result.filter((s) => s.itemType === "mySession"); - if (activeFilters.has("overdue")) - result = result.filter( - (s) => - (s as SignRequestSummary).dueDate && - new Date((s as SignRequestSummary).dueDate) < now, - ); - if (activeFilters.has("signed")) - result = result.filter( - (s) => (s as SignRequestSummary).myStatus === "SIGNED", - ); - if (activeFilters.has("declined")) - result = result.filter( - (s) => (s as SignRequestSummary).myStatus === "DECLINED", - ); - return result; - }; - - const displayedActiveSessions = applyFiltersAndSearch( - sortSessions(activeSessions, "active"), - ); - const displayedCompletedSessions = applyFiltersAndSearch( - sortSessions(completedSessions, "completed"), - ); - - // Create session handler - const handleCreateSession = useCallback(async () => { - if (selectedUserIds.length === 0 || selectedFiles.length !== 1) return; - - setCreating(true); - try { - const selectedFile = selectedFiles[0]; - const stirlingFile = await fileStorage.getStirlingFile( - selectedFile.fileId, - ); - if (!stirlingFile) throw new Error("File not found"); - - const formData = new FormData(); - formData.append("file", stirlingFile, selectedFile.name); - formData.append("workflowType", "SIGNING"); - formData.append("documentName", selectedFile.name); - selectedUserIds.forEach((userId, index) => { - formData.append(`participantUserIds[${index}]`, userId.toString()); - }); - if (dueDate) formData.append("dueDate", dueDate); - formData.append("notifyOnCreate", "true"); - - // Send includeSummaryPage setting as workflowMetadata if enabled - if (includeSummaryPage) { - const workflowMetadata = JSON.stringify({ - includeSummaryPage: true, - }); - formData.append("workflowMetadata", workflowMetadata); - } - - await apiClient.post("/api/v1/security/cert-sign/sessions", formData); - - alert({ - alertType: "success", - title: t("success"), - body: t("signSession.created", "Signing request sent"), - expandable: false, - durationMs: 2500, - }); - - setSelectedUserIds([]); - setDueDate(""); - setIncludeSummaryPage(false); - setShowCreatePanel(false); - await fetchData(); - } catch (error) { - console.error( - "Failed to create session:", - error instanceof Error ? error.message : error, - ); - alert({ - alertType: "error", - title: t("common.error"), - body: t("signSession.createFailed", "Failed to create signing request"), - expandable: false, - durationMs: 3000, - }); - } finally { - setCreating(false); - } - }, [ - selectedUserIds, - dueDate, - selectedFiles, - fetchData, - t, - includeSummaryPage, - ]); - - // Handle clicking a sign request - const handleSignRequestClick = useCallback( - async (request: SignRequestSummary) => { - onClose(); - try { - const [detailResponse, pdfResponse] = await Promise.all([ - apiClient.get( - `/api/v1/security/cert-sign/sign-requests/${request.sessionId}`, - ), - apiClient.get( - `/api/v1/security/cert-sign/sign-requests/${request.sessionId}/document`, - { - responseType: "blob", - }, - ), - ]); - - const pdfFile = new File( - [pdfResponse.data], - detailResponse.data.documentName, - { - type: "application/pdf", - }, - ); - const canSign = - detailResponse.data.myStatus === "PENDING" || - detailResponse.data.myStatus === "NOTIFIED" || - detailResponse.data.myStatus === "VIEWED"; - - setCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID, { - signRequest: detailResponse.data, - pdfFile, - onSign: (certData: FormData) => - handleSign(request.sessionId, certData), - onDecline: () => handleDecline(request.sessionId), - onBack: () => { - clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); - navigationActions.setWorkbench("viewer"); - }, - canSign, - }); - - requestAnimationFrame(() => { - navigationActions.setWorkbench(SIGN_REQUEST_WORKBENCH_TYPE); - }); - } catch (error) { - console.error( - "Failed to load sign request:", - error instanceof Error ? error.message : error, - ); - alert({ - alertType: "error", - title: t("common.error"), - body: t("signRequest.fetchFailed", "Failed to load sign request"), - expandable: false, - durationMs: 3000, - }); - } - }, - [ - onClose, - setCustomWorkbenchViewData, - clearCustomWorkbenchViewData, - navigationActions, - t, - ], - ); - - // Handle clicking a session - const handleSessionClick = useCallback( - async (session: SessionSummary) => { - onClose(); - try { - // First fetch session detail - const detailResponse = await apiClient.get( - `/api/v1/security/cert-sign/sessions/${session.sessionId}`, - ); - - // Determine which endpoint to use based on session state - let pdfFile: File | null = null; - - if (detailResponse.data.finalized) { - // Finalized sessions have signed PDF available - try { - const pdfResponse = await apiClient.get( - `/api/v1/security/cert-sign/sessions/${session.sessionId}/signed-pdf`, - { - responseType: "blob", - }, - ); - pdfFile = new File([pdfResponse.data], session.documentName, { - type: "application/pdf", - }); - } catch (pdfError: unknown) { - const status = (pdfError as { response?: { status?: number } }) - ?.response?.status; - if (status === 404) { - // Finalized but signed PDF not available - backend issue - alert({ - alertType: "warning", - title: t("certSign.sessions.pdfNotReady", "PDF Not Ready"), - body: t( - "certSign.sessions.pdfNotReadyDesc", - "The signed PDF is being generated. Please try again in a moment.", - ), - expandable: false, - durationMs: 3000, - }); - return; - } - throw pdfError; - } - } else { - // For non-finalized sessions, get original PDF (always available) - try { - const pdfResponse = await apiClient.get( - `/api/v1/security/cert-sign/sessions/${session.sessionId}/pdf`, - { - responseType: "blob", - }, - ); - pdfFile = new File([pdfResponse.data], session.documentName, { - type: "application/pdf", - }); - } catch (_error) { - // Fallback if PDF not available - pdfFile = null; - } - } - - setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, { - session: detailResponse.data, - pdfFile, - onFinalize: () => - handleFinalize(session.sessionId, session.documentName), - onLoadSignedPdf: () => - handleLoadSignedPdf(session.sessionId, session.documentName), - onAddParticipants: (userIds: number[], defaultReason?: string) => - handleAddParticipants(session.sessionId, userIds, defaultReason), - onRemoveParticipant: (participantId: number) => - handleRemoveParticipant(session.sessionId, participantId), - onDelete: () => handleDeleteSession(session.sessionId), - onBack: () => { - clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); - navigationActions.setWorkbench("viewer"); - }, - onRefresh: () => handleRefreshSession(session.sessionId), - }); - - requestAnimationFrame(() => { - navigationActions.setWorkbench(SESSION_DETAIL_WORKBENCH_TYPE); - }); - } catch (error) { - console.error( - "Failed to load session:", - error instanceof Error ? error.message : error, - ); - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.sessions.fetchFailed", - "Failed to load session details", - ), - expandable: false, - durationMs: 3000, - }); - } - }, - [ - onClose, - setCustomWorkbenchViewData, - clearCustomWorkbenchViewData, - navigationActions, - t, - ], - ); - - // Action handlers - const handleSign = async (sessionId: string, certificateData: FormData) => { - await apiClient.post( - `/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, - certificateData, - ); - alert({ - alertType: "success", - title: t("success"), - body: t("signRequest.signed", "Document signed successfully"), - expandable: false, - durationMs: 2500, - }); - clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); - navigationActions.setWorkbench("viewer"); - await fetchData(); - }; - - const handleDecline = async (sessionId: string) => { - await apiClient.post( - `/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`, - ); - alert({ - alertType: "success", - title: t("success"), - body: t("signRequest.declined", "Sign request declined"), - expandable: false, - durationMs: 2500, - }); - clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); - navigationActions.setWorkbench("viewer"); - await fetchData(); - }; - - const handleFinalize = async (sessionId: string, documentName: string) => { - const response = await apiClient.post( - `/api/v1/security/cert-sign/sessions/${sessionId}/finalize`, - null, - { - responseType: "blob", - }, - ); - const contentDisposition = response.headers["content-disposition"]; - const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/); - const filename = filenameMatch - ? filenameMatch[1] - : `${documentName}_signed.pdf`; - const signedFile = new File([response.data], filename, { - type: "application/pdf", - }); - await fileActions.addFiles([signedFile], { skipUploadTracking: true }); - alert({ - alertType: "success", - title: t("success"), - body: t("certSign.sessions.finalized", "Session finalized"), - expandable: false, - durationMs: 2500, - }); - clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); - navigationActions.setWorkbench("viewer"); - await fetchData(); - }; - - const handleLoadSignedPdf = async ( - sessionId: string, - documentName: string, - ) => { - const response = await apiClient.get( - `/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`, - { - responseType: "blob", - }, - ); - const contentDisposition = response.headers["content-disposition"]; - const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/); - const filename = filenameMatch - ? filenameMatch[1] - : `${documentName}_signed.pdf`; - const signedFile = new File([response.data], filename, { - type: "application/pdf", - }); - await fileActions.addFiles([signedFile], { skipUploadTracking: true }); - alert({ - alertType: "success", - title: t("success"), - body: t("certSign.sessions.loaded", "Signed PDF loaded"), - expandable: false, - durationMs: 2500, - }); - clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); - navigationActions.setWorkbench("viewer"); - }; - - const handleAddParticipants = async ( - sessionId: string, - userIds: number[], - defaultReason?: string, - ) => { - const requests = userIds.map((userId) => ({ - userId, - defaultReason: defaultReason || undefined, - sendNotification: true, - })); - await apiClient.post( - `/api/v1/security/cert-sign/sessions/${sessionId}/participants`, - requests, - ); - await handleRefreshSession(sessionId); - }; - - const handleRemoveParticipant = async ( - sessionId: string, - participantId: number, - ) => { - await apiClient.delete( - `/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participantId}`, - ); - await handleRefreshSession(sessionId); - }; - - const handleDeleteSession = async (sessionId: string) => { - await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}`); - alert({ - alertType: "success", - title: t("success"), - body: t("certSign.sessions.deleted", "Session deleted"), - expandable: false, - durationMs: 2500, - }); - clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); - navigationActions.setWorkbench("viewer"); - await fetchData(); - }; - - const handleRefreshSession = async (sessionId: string) => { - const response = await apiClient.get( - `/api/v1/security/cert-sign/sessions/${sessionId}`, - ); - // Update workbench data, preserving PDF and callbacks - setCustomWorkbenchViewData( - SESSION_DETAIL_WORKBENCH_ID, - (prevData: Record) => ({ - ...prevData, - session: response.data, - }), - ); - }; - - if (typeof document === "undefined") return null; - - // Shared card content — rendered inside either the portal (desktop/tablet) or Drawer (phone) - const popoutCard = ( -
- {/* Header */} -
- -
- {showCreatePanel - ? t("quickAccess.createSession", "Create Signing Request") - : groupSigningEnabled && activeTab === "active" - ? t("quickAccess.activeSessions", "Active Sessions") - : groupSigningEnabled - ? t("quickAccess.completedSessions", "Completed Sessions") - : t("quickAccess.sign", "Sign")} -
-
- {!showCreatePanel && ( - - )} - -
-
- - {/* Quick sign tools */} - {!showCreatePanel && ( -
-
- {t("quickAccess.signYourself", "Sign Yourself")} -
-
- - -
-
- )} - - {/* Signature Requests section label + Tab Navigation */} - {!showCreatePanel && groupSigningEnabled && ( - <> -
- - {t("quickAccess.signatureRequests", "Signature Requests")} - - -
-
- - -
- - )} - - {/* Search + filter bar */} - {!showCreatePanel && groupSigningEnabled && ( -
- setSearchQuery(e.target.value)} - /> -
- {filterOptions.map((f) => ( - - ))} -
-
- )} - - {/* Body */} - {groupSigningEnabled && ( -
- {showCreatePanel ? ( - - ) : activeTab === "active" ? ( - { - if (item.itemType === "signRequest") { - handleSignRequestClick(item as SignRequestSummary); - } else { - handleSessionClick(item as SessionSummary); - } - }} - /> - ) : ( - { - if (item.itemType === "signRequest") { - handleSignRequestClick(item as SignRequestSummary); - } else { - handleSessionClick(item as SessionSummary); - } - }} - /> - )} -
- )} - - {/* Footer */} - {groupSigningEnabled && showCreatePanel && ( -
- -
- )} -
- ); - - // Phone: bottom-sheet Drawer (full height) - if (isPhone) { - return ( - - {popoutCard} - - ); - } - - // Desktop / tablet: fixed-position portal - return createPortal( -
e.stopPropagation()} - > - {popoutCard} -
, - document.body, - ); -}; - -export default SignPopout; diff --git a/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx index 5d7f0f15d3..901ae80732 100644 --- a/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx +++ b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx @@ -79,10 +79,7 @@ export const ConfigureSignatureDefaultsStep: React.FC< {t("groupSigning.steps.back", "Back")}
diff --git a/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.tsx index f236ec593d..66eeee0689 100644 --- a/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.tsx +++ b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.tsx @@ -56,7 +56,9 @@ export const ReviewSessionStep: React.FC = ({ backgroundColor: "var(--mantine-color-default-hover)", }} > - {selectedFile.name} + + {selectedFile.name} + {selectedFile.size && ( {(selectedFile.size / 1024 / 1024).toFixed(2)} MB @@ -177,9 +179,18 @@ export const ReviewSessionStep: React.FC = ({ diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx index 97d0b35009..aaedc076d3 100644 --- a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx @@ -49,8 +49,8 @@ export const SelectDocumentStep: React.FC = ({ -
- +
+ {selectedFile?.name} {selectedFile?.size && ( @@ -63,10 +63,7 @@ export const SelectDocumentStep: React.FC = ({
)} diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx index e32458937c..3baa15b7ba 100644 --- a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx @@ -64,10 +64,7 @@ export const SelectParticipantsStep: React.FC = ({ disabled={!hasParticipants || disabled} style={{ flex: 1 }} > - {t( - "groupSigning.steps.selectParticipants.continue", - "Continue to Signature Settings", - )} + {t("groupSigning.steps.selectParticipants.continue", "Continue")} diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx index 96f826f49f..af50c8ceab 100644 --- a/frontend/editor/src/core/components/tools/ToolPicker.tsx +++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx @@ -10,6 +10,7 @@ import NoToolsFound from "@app/components/tools/shared/NoToolsFound"; import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons"; import ToolButton from "@app/components/tools/toolPicker/ToolButton"; import { useToolWorkflowData } from "@app/contexts/ToolWorkflowContext"; +import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount"; import { ToolId } from "@app/types/toolId"; import { getSubcategoryLabel } from "@app/data/toolsTaxonomy"; import { ToolPickerFooterExtensions } from "@app/components/tools/toolPicker/ToolPickerFooterExtensions"; @@ -78,15 +79,29 @@ const ToolPicker = ({ [visibleSections], ); + // Signing items needing the user's attention: requests awaiting their + // signature, plus their own sessions newly signed since last opened + // (0 when group signing is disabled). + const signingBadgeCount = useSigningBadgeCount(); + const recommendedItems = useMemo(() => { - if (!quickSection) - return [] as Array<{ id: string; tool: ToolRegistryEntry }>; const items: Array<{ id: string; tool: ToolRegistryEntry }> = []; - quickSection.subcategories.forEach((sc: SubcategoryGroup) => + quickSection?.subcategories.forEach((sc: SubcategoryGroup) => sc.tools.forEach((toolEntry) => items.push(toolEntry)), ); + // While signing needs the user's attention, surface Shared Signing at the + // top of Recommended so it's easy to find without hunting in the Signing group. + if (signingBadgeCount > 0) { + const sharedSignTool = toolRegistry["sharedSign" as ToolId]; + if (sharedSignTool) { + return [ + { id: "sharedSign", tool: sharedSignTool }, + ...items.filter(({ id }) => id !== "sharedSign"), + ]; + } + } return items; - }, [quickSection]); + }, [quickSection, signingBadgeCount, toolRegistry]); const allSection = useMemo( () => visibleSections.find((s) => s.key === "all"), @@ -158,6 +173,9 @@ const ToolPicker = ({ onSelect={onSelect} hasStars showDescription + badgeCount={ + id === "sharedSign" ? signingBadgeCount : undefined + } /> ))}
@@ -212,6 +230,9 @@ const ToolPicker = ({ isSelected={selectedToolKey === id} onSelect={onSelect} hasStars + badgeCount={ + id === "sharedSign" ? signingBadgeCount : undefined + } /> ))}
diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateSelector.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.tsx index 959ea4b192..be2f1d6102 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertificateSelector.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.tsx @@ -183,20 +183,34 @@ export const CertificateSelector: React.FC = ({ /> )} - {/* PEM */} + {/* PEM — private key and certificate are two separate files */} {uploadFormat === "PEM" && ( - - onPrivateKeyFileChange(file || null)} - accept=".pem,.der,.key" - disabled={disabled} - placeholder={t( - "certSign.choosePrivateKey", - "Choose Private Key File", - )} - /> - {privateKeyFile && ( + + + + {t( + "certSign.pemPrivateKeyLabel", + "Private key (.pem / .key)", + )} + + onPrivateKeyFileChange(file || null)} + accept=".pem,.der,.key" + disabled={disabled} + placeholder={t( + "certSign.choosePrivateKey", + "Choose Private Key File", + )} + /> + + + + {t( + "certSign.pemCertificateLabel", + "Certificate (.pem / .crt)", + )} + onCertFileChange(file || null)} @@ -207,7 +221,7 @@ export const CertificateSelector: React.FC = ({ "Choose Certificate File", )} /> - )} + )} diff --git a/frontend/editor/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx b/frontend/editor/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx deleted file mode 100644 index 3408f76b50..0000000000 --- a/frontend/editor/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx +++ /dev/null @@ -1,519 +0,0 @@ -import { useState, useEffect, useMemo, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { - Stack, - Paper, - Text, - Group, - Badge, - Button, - Divider, - Modal, - SegmentedControl, -} from "@mantine/core"; -import { useIsPhone } from "@app/hooks/useIsMobile"; -import { alert } from "@app/components/toast"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import DeleteIcon from "@mui/icons-material/Delete"; -import ZoomInIcon from "@mui/icons-material/ZoomIn"; -import ZoomOutIcon from "@mui/icons-material/ZoomOut"; -import ZoomOutMapIcon from "@mui/icons-material/ZoomOutMap"; -import { Z_INDEX_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -import { SessionDetail } from "@app/types/signingSession"; -import { - LocalEmbedPDFWithAnnotations, - SignaturePreview, - AnnotationAPI, -} from "@app/components/viewer/LocalEmbedPDFWithAnnotations"; -import { getFileColor } from "@app/components/pageEditor/fileColors"; -import { ParticipantListPanel } from "@app/components/tools/certSign/panels/ParticipantListPanel"; -import { SessionActionsPanel } from "@app/components/tools/certSign/panels/SessionActionsPanel"; -import { AddParticipantsFlow } from "@app/components/tools/certSign/modals/AddParticipantsFlow"; - -export interface SessionDetailWorkbenchData { - session: SessionDetail; - pdfFile: File | null; - onFinalize: () => Promise; - onLoadSignedPdf: () => Promise; - onAddParticipants: ( - userIds: number[], - defaultReason?: string, - ) => Promise; - onRemoveParticipant: (participantId: number) => Promise; - onDelete: () => Promise; - onBack: () => void; - onRefresh: () => Promise; -} - -interface SessionDetailWorkbenchViewProps { - data: SessionDetailWorkbenchData; -} - -const SessionDetailWorkbenchView = ({ - data, -}: SessionDetailWorkbenchViewProps) => { - const { t } = useTranslation(); - const isPhone = useIsPhone(); - const [mobilePanel, setMobilePanel] = useState< - "participants" | "pdf" | "actions" - >("pdf"); - const { - session, - pdfFile, - onFinalize, - onLoadSignedPdf, - onAddParticipants, - onRemoveParticipant, - onDelete, - onBack, - onRefresh, - } = data; - - // Ref for annotation API (to access zoom controls) - const annotationApiRef = useRef(null); - - const [deleteModalOpen, setDeleteModalOpen] = useState(false); - const [addParticipantsModalOpen, setAddParticipantsModalOpen] = - useState(false); - const [finalizing, setFinalizing] = useState(false); - const [deleting, setDeleting] = useState(false); - const [loadingPdf, setLoadingPdf] = useState(false); - - // Auto-refresh every 30 seconds when not finalized - useEffect(() => { - if (!session.finalized) { - const interval = setInterval(() => { - onRefresh(); - }, 30000); - return () => clearInterval(interval); - } - }, [session.finalized, onRefresh]); - - const handleAddParticipants = async ( - userIds: number[], - defaultReason?: string, - ) => { - try { - await onAddParticipants(userIds, defaultReason); - alert({ - alertType: "success", - title: t("success"), - body: t( - "certSign.collab.sessionDetail.participantsAdded", - "Participants added successfully", - ), - }); - } catch (_error) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.sessionDetail.addParticipantsError", - "Failed to add participants", - ), - }); - throw _error; // Re-throw so modal can handle loading state - } - }; - - const handleRemoveParticipant = async (participantId: number) => { - try { - await onRemoveParticipant(participantId); - alert({ - alertType: "success", - title: t("success"), - body: t( - "certSign.collab.sessionDetail.participantRemoved", - "Participant removed", - ), - }); - } catch (_error) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.sessionDetail.removeParticipantError", - "Failed to remove participant", - ), - }); - } - }; - - const handleFinalize = async () => { - setFinalizing(true); - try { - await onFinalize(); - } catch (_error) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.sessionDetail.finalizeError", - "Failed to finalize session", - ), - }); - } finally { - setFinalizing(false); - } - }; - - const handleDelete = async () => { - setDeleting(true); - try { - await onDelete(); - setDeleteModalOpen(false); - alert({ - alertType: "success", - title: t("success"), - body: t("certSign.collab.sessionDetail.deleted", "Session deleted"), - }); - } catch (_error) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.sessionDetail.deleteError", - "Failed to delete session", - ), - }); - setDeleting(false); - } - }; - - const handleLoadSignedPdf = async () => { - setLoadingPdf(true); - try { - await onLoadSignedPdf(); - } catch (_error) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.sessionDetail.loadPdfError", - "Failed to load signed PDF", - ), - }); - } finally { - setLoadingPdf(false); - } - }; - - // Extract wet signatures from all participants for preview - const wetSignaturePreviews = useMemo(() => { - const previews: SignaturePreview[] = []; - - session.participants.forEach((participant, participantIndex) => { - if (participant.wetSignatures && participant.wetSignatures.length > 0) { - const color = getFileColor(participantIndex); - const participantName = participant.name || participant.email; - participant.wetSignatures.forEach((wetSig, sigIndex) => { - previews.push({ - id: `participant-${participant.userId}-sig-${sigIndex}`, - pageIndex: wetSig.page, - x: wetSig.x, - y: wetSig.y, - width: wetSig.width, - height: wetSig.height, - signatureData: wetSig.data, - signatureType: "image" as const, - color, - participantName, - }); - }); - } - }); - - return previews; - }, [session.participants]); - - return ( -
- {/* Top Control Bar */} - - - - - - - - - {session.documentName} - - - {session.finalized - ? t("certSign.collab.sessionList.finalized", "Finalized") - : t("certSign.collab.sessionList.active", "Active")} - - - {!isPhone && ( - - {session.ownerEmail && - `${t("certSign.collab.sessionDetail.owner", "Owner")}: ${session.ownerEmail}`} - {session.ownerEmail && " • "} - {new Date(session.createdAt).toLocaleDateString()} - - )} - - - - - {/* Zoom Controls — hidden on phone (pinch-to-zoom available) */} - {!isPhone && ( - - - - - - )} - - {/* Delete Session Button */} - {!session.finalized && ( - - )} - - - - - {/* Main Content Area */} - {isPhone ? ( - // Phone: single-panel view — all three panels stay mounted (CSS display:none preserves state) -
- - - - -
- -
- - - setAddParticipantsModalOpen(true)} - onFinalize={handleFinalize} - onLoadSignedPdf={handleLoadSignedPdf} - finalizing={finalizing} - loadingPdf={loadingPdf} - /> - -
- ) : ( - // Desktop/tablet: three-column flex layout -
- {/* Left Panel - Participants */} - - - - - {/* Center - PDF Viewer */} -
- -
- - {/* Right Panel - Session Actions */} - - setAddParticipantsModalOpen(true)} - onFinalize={handleFinalize} - onLoadSignedPdf={handleLoadSignedPdf} - finalizing={finalizing} - loadingPdf={loadingPdf} - /> - -
- )} - - {/* Phone bottom navigation */} - {isPhone && ( - - setMobilePanel(v as typeof mobilePanel)} - data={[ - { - value: "participants", - label: t("certSign.mobile.panelPeople", "People"), - }, - { - value: "pdf", - label: t("certSign.mobile.panelDocument", "Document"), - }, - { - value: "actions", - label: t("certSign.mobile.panelActions", "Actions"), - }, - ]} - /> - - )} - - {/* Add Participants Modal */} - setAddParticipantsModalOpen(false)} - onSubmit={handleAddParticipants} - /> - - {/* Delete Confirmation Modal */} - setDeleteModalOpen(false)} - title={t( - "certSign.collab.sessionDetail.deleteSession", - "Delete Session", - )} - > - - - {t( - "certSign.collab.sessionDetail.deleteConfirm", - "Are you sure? This cannot be undone.", - )} - - - - - - - -
- ); -}; - -export default SessionDetailWorkbenchView; diff --git a/frontend/editor/src/core/components/tools/certSign/SignControlsStrip.module.css b/frontend/editor/src/core/components/tools/certSign/SignControlsStrip.module.css deleted file mode 100644 index b18b899565..0000000000 --- a/frontend/editor/src/core/components/tools/certSign/SignControlsStrip.module.css +++ /dev/null @@ -1,648 +0,0 @@ -.topBar { - position: relative; - display: flex; - align-items: center; - gap: 12px; - height: 52px; - padding: 0 16px; - background: var(--bg-toolbar); - border-bottom: 1px solid var(--border-default); -} - -/* Mobile split: title-only bar at top (keep border-bottom) */ -.topBar[data-mobile-section="title"] { - border-bottom: 1px solid var(--border-default); -} - -/* Mobile split: controls-only bar at bottom; right section fills and centers */ -.topBar[data-mobile-section="controls"] .right { - flex: 1; - justify-content: center; -} - -.left { - min-width: 0; - display: flex; - align-items: center; - gap: 10px; - flex: 1; -} - -.fileTitle { - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.viewerFileRow { - min-width: 0; - display: flex; - align-items: center; - gap: 10px; - flex: 1; - overflow: hidden; - justify-content: flex-start; -} - -.fileName { - min-width: 0; - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.subText { - font-size: 11px; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.activeFilesRow { - min-width: 0; - display: flex; - align-items: center; - gap: 10px; -} - -.fileEditorHeaderRow { - min-width: 0; - display: flex; - align-items: center; - gap: 10px; - flex: 1; - justify-content: space-between; -} - -.fileEditorActions { - display: flex; - align-items: center; - flex: 0 0 auto; -} - -.activeFilesContent { - min-width: 0; - display: flex; - align-items: center; - gap: 12px; - flex: 1; -} - -.activeFilesSummary { - font-size: 11px; - color: var(--text-muted); - white-space: nowrap; - margin-left: auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} - -.right { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - flex: 0 0 auto; - min-width: 0; - flex-wrap: nowrap; -} - -.downloadSlot { - display: flex; - flex: 0 0 auto; -} - -.actionIcon { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - flex-shrink: 0; -} - -.actionIcon > * { - flex-shrink: 0; - display: block; -} - -.actionLabel { - font-size: 13px; - font-weight: 600; - line-height: 1; - white-space: nowrap; - display: inline-block; -} - -.viewerControls { - display: flex; - align-items: center; - gap: 4px; - min-width: 0; -} - -/* Responsive: stack controls under the title row on narrow widths */ -@media (max-width: 1200px) { - .topBar { - height: auto; - min-height: 52px; - flex-wrap: wrap; - align-items: center; - gap: 8px; - padding: 8px 12px; - } - - /* Keep the long viewer toolbar usable if it still overflows */ - .viewerControls { - flex: 0 1 auto; - min-width: 0; - max-width: 100%; - overflow-x: auto; - overflow-y: hidden; - padding-bottom: 2px; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - } - - .viewerControls::-webkit-scrollbar { - display: none; - } - - /* Save horizontal space on smaller screens */ - .zoomSlider { - width: 64px; - } - - /* Keep download pinned to the far right on row 2 */ - .downloadSlot { - margin-left: auto; - } - - /* In file editor (Active files), keep BOTH actions on the left */ - .topBar[data-view="fileEditor"] .downloadSlot { - margin-left: 0; - } - - .topBar[data-view="fileEditor"] .right { - flex-wrap: wrap; - } -} - -/* When the title and controls wrap to two rows, center both rows */ -.topBar[data-wrapped="true"] .left, -.topBar[data-wrapped="true"] .right { - flex: 0 0 100%; - width: 100%; - justify-content: center; -} - -.topBar[data-wrapped="true"] .viewerFileRow { - justify-content: center; -} - -/* On very narrow viewports, prefer wrapping to extra rows over horizontal scrolling */ -@media (max-width: 560px) { - .topBar[data-view="viewer"][data-mobile="false"] .right { - flex-wrap: wrap; - } - - .topBar[data-view="viewer"][data-mobile="false"] .viewerControls { - overflow: visible; - padding-bottom: 0; - flex-wrap: wrap; - row-gap: 6px; - } - - .topBar[data-view="viewer"][data-mobile="false"] .viewerControls .divider { - display: none; - } - - /* Push zoom controls to their own row when space is tight */ - .topBar[data-view="viewer"][data-mobile="false"] .zoomPill { - flex: 1 1 100%; - justify-content: flex-start; - } - - /* Make download drop to its own row if needed */ - .topBar[data-view="viewer"][data-mobile="false"] .downloadSlot { - flex: 1 1 100%; - justify-content: flex-start; - margin-left: 0; - } -} - -/* Mobile workbench tweaks (use runtime mobile mode, not viewport width) */ -@media (max-width: 1200px) { - .topBar[data-view="viewer"][data-mobile="true"] .downloadSlot { - margin-left: 0; - } -} - -.topBar[data-view="viewer"][data-mobile="true"][data-wrapped="false"] .right { - justify-content: flex-start; - flex-wrap: nowrap; -} - -.topBar[data-view="viewer"][data-mobile="true"][data-wrapped="false"] - .viewerControls { - flex: 1 1 auto; - overflow: hidden; -} - -/* Tighten sizing specifically inside the viewer toolbar cluster */ -.viewerControls .iconButton { - width: 28px; - height: 28px; -} - -.pagePill { - display: inline-flex; - align-items: center; - gap: 6px; - height: 24px; - padding: 0; - border-radius: 999px; - border: none; - background: transparent; - color: var(--text-secondary); -} - -.pageInput { - width: 30px; - height: 22px; - border: none; - background: transparent; - color: var(--text-primary); - font-size: 12px; - font-weight: 600; - text-align: center; - outline: none; - padding: 0; -} - -.pageInput::-webkit-outer-spin-button, -.pageInput::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.pageDivider { - color: var(--text-muted); - font-size: 12px; -} - -.pageTotal { - min-width: 18px; - text-align: left; - color: var(--text-muted); - font-size: 12px; - font-weight: 600; -} - -.zoomPill { - display: inline-flex; - align-items: center; - gap: 6px; - height: 24px; - padding: 0; - border-radius: 999px; - border: none; - background: transparent; - color: var(--text-secondary); -} - -.zoomButton { - width: 24px; - height: 24px; - border-radius: 999px; - border: none; - background: transparent; - color: var(--text-secondary); - font-size: 16px; - line-height: 1; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: - background-color 0.15s ease, - color 0.15s ease; -} - -.zoomButton:hover { - background: var(--hover-bg); - color: var(--text-primary); -} - -.zoomSlider { - width: 80px; - accent-color: var(--mantine-color-blue-6, #3b82f6); -} - -.zoomLabel { - min-width: 36px; - text-align: right; - font-size: 12px; - font-weight: 600; - color: var(--text-muted); -} - -.divider { - width: 1px; - height: 18px; - background: var(--border-default); - margin: 0 4px; -} - -.iconButton { - width: 32px; - height: 32px; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--text-secondary); - transition: - background-color 0.15s ease, - color 0.15s ease; -} - -.iconButton.iconTextButton { - width: auto; - min-width: 0; - height: 32px; - padding: 0 12px; - border-radius: 999px; - gap: 8px; - justify-content: flex-start; -} - -@media (max-width: 420px) { - .iconButton.iconTextButton { - padding: 0 10px; - } - - .iconButton.iconTextButton .actionLabel { - display: none; - } -} - -.iconButton:hover { - background: var(--hover-bg); - color: var(--text-primary); -} - -.iconButton:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.iconButton:disabled:hover { - background: transparent; - color: var(--text-secondary); -} - -/* --- Sign controls strip (part of normal layout) --- */ -.signStrip { - position: relative; - width: 100%; - border-top: 1px solid var(--border-default); - background: var(--bg-toolbar); -} - -.signStrip[data-open="true"] { - /* Always visible when open */ -} - -.signStripInner { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; -} - -.signStripControls { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - flex-wrap: wrap; -} - -.signStripOptions { - display: flex; - align-items: center; - gap: 8px; - flex: 0 0 auto; - flex-wrap: nowrap; -} - -.signStripActions { - display: flex; - align-items: center; - gap: 8px; - flex: 1 1 auto; - justify-content: flex-end; - min-width: 0; - flex-wrap: nowrap; -} - -.signStripSpacer { - flex: 1 1 auto; -} - -.signStripHint { - display: inline-flex; - margin-left: 6px; -} - -.signStripButton { - color: var(--text-secondary); -} - -.signStripPill { - height: 28px; - border-radius: 999px; -} - -/* --- Signing UI (strip) --- */ -.signingRow { - width: 100%; - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} - -.signingLeft { - display: inline-flex; - align-items: center; - gap: 8px; - flex: 0 0 auto; - order: 0; -} - -.signingCenter { - flex: 1 1 auto; - min-width: 120px; - order: 1; -} - -.signingMode { - display: inline-flex; - align-items: center; - flex: 0 0 auto; - order: 2; -} - -.signingRight { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 12px; - flex: 0 0 auto; - order: 3; -} - -.signingPreviewButton { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0; - border-radius: 10px; - border: none; - background: transparent; - cursor: pointer; -} - -.signingPreviewButton:hover { - background: color-mix(in srgb, var(--bg-toolbar) 75%, transparent); -} - -.signingPreviewFrame { - background: #ffffff; - border-radius: 8px; - padding: 2px 8px; - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 28px; -} - -.signingPreviewChevron { - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); -} - -.signingHint { - margin-left: 0; -} - -.signingHintUnit { - margin: 0 12px; -} - -.keyCap { - display: inline-flex; - vertical-align: middle; - color: var(--text-secondary); - margin: 0 8px; -} - -.signingStatus { - white-space: nowrap; -} - -.signingDivider { - width: 1px; - height: 18px; - background: var(--border-default); - margin: 0 6px; - flex: 0 0 auto; -} - -.signStripCloseButton { - width: 28px; - height: 28px; -} - -/* Mobile-only delete button (no Backspace key); hidden on desktop */ -.signStripMobileDelete { - display: none; -} - -/* Sign strip mode: Place vs Move (radio-style segmented control) */ -.signStripModeRadio { - flex: 0 0 auto; -} - -.signStripModeRadio [data-mantine-segmented-control-indicator] { - background: var(--bg-elevated); - border-radius: var(--mantine-radius-xl); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); -} - -.signingTitle { - /* Add styles for signing title */ -} - -/* Mobile: row 1 = close + radio (no title); row 2 = preview + apply */ -@media (max-width: 720px) { - .signingLeft { - order: 0; - } - - .signingTitle { - display: none; - } - - .signingCenter { - flex: 1 1 auto; - min-width: 0; - order: 2; - } - - .signingMode { - order: 1; - } - - .signingRight { - order: 3; - width: 100%; - justify-content: flex-start; - margin-left: 0; - padding-top: 6px; - border-top: 1px solid var(--border-default); - } - - .signStripMobileDelete { - display: inline-flex; - width: 28px; - height: 28px; - } -} - -/* When the strip gets narrow, split into two rows: - options on row 1, actions on row 2 (with divider line). */ -@media (max-width: 720px) { - .signStripOptions { - flex: 1 1 100%; - width: 100%; - } - - .signStripActions { - flex: 1 1 100%; - width: 100%; - justify-content: flex-start; - padding-top: 8px; - } -} diff --git a/frontend/editor/src/core/components/tools/certSign/SignControlsStrip.tsx b/frontend/editor/src/core/components/tools/certSign/SignControlsStrip.tsx deleted file mode 100644 index ba5b3a8276..0000000000 --- a/frontend/editor/src/core/components/tools/certSign/SignControlsStrip.tsx +++ /dev/null @@ -1,790 +0,0 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - ActionIcon, - Box, - Button, - Group, - Menu, - Modal, - SegmentedControl, - Stack, - Text, -} from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import DrawIcon from "@mui/icons-material/Draw"; -import ImageIcon from "@mui/icons-material/Image"; -import OpenWithIcon from "@mui/icons-material/OpenWith"; -import TextFieldsIcon from "@mui/icons-material/TextFields"; -import CloseIcon from "@mui/icons-material/Close"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import CheckIcon from "@mui/icons-material/Check"; - -import { - DEFAULT_PARAMETERS, - type SignParameters, -} from "@app/hooks/tools/sign/useSignParameters"; -import { - useSavedSignatures, - type SavedSignature, -} from "@app/hooks/tools/sign/useSavedSignatures"; -import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas"; -import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; -import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont"; -import { buildSignaturePreview } from "@app/utils/signaturePreview"; - -import styles from "@app/components/tools/certSign/SignControlsStrip.module.css"; - -interface SignControlsStripProps { - visible: boolean; - placementMode: boolean; - onPlacementModeChange: (active: boolean) => void; - onSignatureSelected: (config: SignParameters) => void; - onComplete: () => void; - canComplete: boolean; - signatureConfig: SignParameters | null; - hasSelectedAnnotation?: boolean; - onDeleteSelected?: () => void; -} - -export default function SignControlsStrip({ - visible, - placementMode, - onPlacementModeChange, - onSignatureSelected, - onComplete, - canComplete, - signatureConfig, - hasSelectedAnnotation = false, - onDeleteSelected, -}: SignControlsStripProps) { - const { t } = useTranslation(); - const { - savedSignatures, - addSignature, - removeSignature, - isAtCapacity, - byTypeCounts, - } = useSavedSignatures(); - - const [createSignatureType, setCreateSignatureType] = useState< - "canvas" | "text" | "image" | null - >(null); - const [canvasColorPickerOpen, setCanvasColorPickerOpen] = useState(false); - const [canvasColor, setCanvasColor] = useState("#000000"); - const [canvasPenSize, setCanvasPenSize] = useState(2); - const [canvasPenSizeInput, setCanvasPenSizeInput] = useState("2"); - const latestCanvasDataRef = useRef(undefined); - - const fileInputRef = useRef(null); - const [textSignerName, setTextSignerName] = useState( - DEFAULT_PARAMETERS.signerName ?? "", - ); - const [textFontFamily, setTextFontFamily] = useState( - DEFAULT_PARAMETERS.fontFamily ?? "Helvetica", - ); - const [textFontSize, setTextFontSize] = useState( - DEFAULT_PARAMETERS.fontSize ?? 16, - ); - const [textColor, setTextColor] = useState( - DEFAULT_PARAMETERS.textColor ?? "#000000", - ); - - const renderSavedSignaturePreview = useCallback( - (sig: SavedSignature) => { - if (sig.type === "text") { - return ( - - - {sig.signerName} - - - ); - } - - return ( - - - - ); - }, - [t], - ); - - const sortedSavedSignatures = useMemo(() => { - if (!savedSignatures.length) return []; - return [...savedSignatures].sort( - (a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt), - ); - }, [savedSignatures]); - - const hasAutoSelected = useRef(false); - useEffect(() => { - if (hasAutoSelected.current) return; - if (!sortedSavedSignatures.length) return; - if (signatureConfig?.signatureData) return; - - hasAutoSelected.current = true; - const lastSig = sortedSavedSignatures[0]; - if (lastSig.type === "text") { - onSignatureSelected({ - ...DEFAULT_PARAMETERS, - signatureType: "text", - signerName: lastSig.signerName, - fontFamily: lastSig.fontFamily, - fontSize: lastSig.fontSize, - textColor: lastSig.textColor, - signatureData: lastSig.dataUrl, - }); - } else { - onSignatureSelected({ - ...DEFAULT_PARAMETERS, - signatureType: lastSig.type, - signatureData: lastSig.dataUrl, - }); - } - }, [ - sortedSavedSignatures, - signatureConfig?.signatureData, - onSignatureSelected, - ]); - - const beginPlacement = useCallback( - (config: SignParameters) => { - const nextConfig: SignParameters = { - ...DEFAULT_PARAMETERS, - ...config, - }; - - onSignatureSelected(nextConfig); - onPlacementModeChange(true); - }, - [onSignatureSelected, onPlacementModeChange], - ); - - const applySavedSignature = useCallback( - (sig: SavedSignature) => { - if (sig.type === "text") { - beginPlacement({ - signatureType: "text", - signerName: sig.signerName, - fontFamily: sig.fontFamily, - fontSize: sig.fontSize, - textColor: sig.textColor, - signatureData: sig.dataUrl, - }); - return; - } - beginPlacement({ signatureType: sig.type, signatureData: sig.dataUrl }); - }, - [beginPlacement], - ); - - const pausePlacement = useCallback(() => { - onPlacementModeChange(false); - }, [onPlacementModeChange]); - - const resumePlacement = useCallback(() => { - onPlacementModeChange(true); - }, [onPlacementModeChange]); - - const handleCreateSignature = useCallback( - (type: "canvas" | "text" | "image") => { - if (type === "image") { - fileInputRef.current?.click(); - return; - } - setCreateSignatureType(type); - if (type === "canvas") { - setCanvasColor("#000000"); - setCanvasPenSize(2); - setCanvasPenSizeInput("2"); - latestCanvasDataRef.current = undefined; - } else if (type === "text") { - setTextSignerName(""); - } - }, - [], - ); - - const handleCancelCreate = useCallback(() => { - setCreateSignatureType(null); - }, []); - - const saveTextToLibrary = useCallback(async () => { - const signerName = textSignerName.trim(); - if (!signerName || isAtCapacity) return null; - - const preview = await buildSignaturePreview({ - signatureType: "text", - signerName, - fontFamily: textFontFamily, - fontSize: textFontSize, - textColor, - }); - if (!preview?.dataUrl) return null; - - const nextIndex = (byTypeCounts?.text ?? 0) + 1; - const baseLabel = t( - "certSign.collab.signRequest.saved.defaultTextLabel", - "Typed signature", - ); - await addSignature( - { - type: "text", - dataUrl: preview.dataUrl, - signerName, - fontFamily: textFontFamily, - fontSize: textFontSize, - textColor, - }, - `${baseLabel} ${nextIndex}`, - "localStorage", - ); - return { - signerName, - fontFamily: textFontFamily, - fontSize: textFontSize, - textColor, - dataUrl: preview.dataUrl, - }; - }, [ - addSignature, - byTypeCounts?.text, - isAtCapacity, - t, - textColor, - textFontFamily, - textFontSize, - textSignerName, - ]); - - const saveImageToLibrary = useCallback( - async (dataUrl: string) => { - if (!dataUrl || isAtCapacity) return; - const nextIndex = (byTypeCounts?.image ?? 0) + 1; - const baseLabel = t( - "certSign.collab.signRequest.saved.defaultImageLabel", - "Uploaded signature", - ); - await addSignature( - { type: "image", dataUrl }, - `${baseLabel} ${nextIndex}`, - "localStorage", - ); - }, - [addSignature, byTypeCounts?.image, isAtCapacity, t], - ); - - const readFileAsDataUrl = useCallback(async (file: File): Promise => { - return await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const value = reader.result; - if (typeof value === "string") resolve(value); - else reject(new Error("Failed to read image as data URL")); - }; - reader.onerror = () => - reject(reader.error ?? new Error("Failed to read file")); - reader.readAsDataURL(file); - }); - }, []); - - const saveCanvasToLibrary = useCallback( - async (dataUrl: string) => { - if (!dataUrl || isAtCapacity) return; - const nextIndex = (byTypeCounts?.canvas ?? 0) + 1; - const baseLabel = t( - "certSign.collab.signRequest.saved.defaultCanvasLabel", - "Drawing signature", - ); - await addSignature( - { type: "canvas", dataUrl }, - `${baseLabel} ${nextIndex}`, - "localStorage", - ); - }, - [addSignature, byTypeCounts?.canvas, isAtCapacity, t], - ); - - useEffect(() => { - if (!visible || !signatureConfig) return; - - const onKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - const isTypingTarget = - target?.tagName === "INPUT" || - target?.tagName === "TEXTAREA" || - (target as any)?.isContentEditable; - if (isTypingTarget) return; - - if (event.key === "Escape") { - pausePlacement(); - return; - } - - if (event.key === "Backspace") { - event.preventDefault(); - onDeleteSelected?.(); - } - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [pausePlacement, onDeleteSelected, signatureConfig, visible]); - - const handleCanvasSignatureChange = useCallback((dataUrl: string | null) => { - latestCanvasDataRef.current = dataUrl ?? undefined; - }, []); - - const handleDrawingComplete = useCallback(async () => { - const dataUrl = latestCanvasDataRef.current; - if (!dataUrl) return; - await saveCanvasToLibrary(dataUrl); - beginPlacement({ signatureType: "canvas", signatureData: dataUrl }); - setCreateSignatureType(null); - latestCanvasDataRef.current = undefined; - }, [saveCanvasToLibrary, beginPlacement]); - - const handleSaveText = useCallback(async () => { - const saved = await saveTextToLibrary(); - if (!saved) return; - beginPlacement({ - signatureType: "text", - signerName: saved.signerName, - fontFamily: saved.fontFamily, - fontSize: saved.fontSize, - textColor: saved.textColor, - signatureData: saved.dataUrl, - }); - setCreateSignatureType(null); - setTextSignerName(""); - }, [saveTextToLibrary, beginPlacement]); - - const handleImageSelected = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] ?? null; - e.target.value = ""; - if (!file) return; - try { - const dataUrl = await readFileAsDataUrl(file); - await saveImageToLibrary(dataUrl); - beginPlacement({ signatureType: "image", signatureData: dataUrl }); - setCreateSignatureType(null); - } catch (err) { - console.error("Failed to read signature image:", err); - } - }, - [readFileAsDataUrl, saveImageToLibrary, beginPlacement], - ); - - if (!visible || !signatureConfig) return null; - - const previewNode = - signatureConfig.signatureType === "text" ? ( -
- {(signatureConfig.signerName ?? "").trim() || - t("certSign.collab.signRequest.preview.textFallback", "Signature")} -
- ) : ( -
- {signatureConfig.signatureData ? ( - {t( - ) : ( - - {t("certSign.collab.signRequest.preview.missing", "No preview")} - - )} -
- ); - - return ( -
-
-
-
- - {t("certSign.collab.signRequest.signingTitle", "Signing")} - -
- - - - {/* Draw Signature — auto-opens its inner canvas modal directly */} - {createSignatureType === "canvas" && ( - setCanvasColorPickerOpen(true)} - onPenSizeChange={(size) => { - setCanvasPenSize(size); - setCanvasPenSizeInput(String(size)); - }} - onPenSizeInputChange={(input) => { - setCanvasPenSizeInput(input); - const next = Number(input); - if (Number.isFinite(next) && next > 0 && next <= 50) { - setCanvasPenSize(next); - } - }} - onSignatureDataChange={handleCanvasSignatureChange} - onDrawingComplete={handleDrawingComplete} - onModalClose={handleCancelCreate} - width={600} - height={200} - /> - )} - - {/* Type Signature Modal */} - - - - {t( - "certSign.collab.signRequest.text.modalHint", - "Enter your name, then click Continue to place it on the PDF.", - )} - - - - - - - - - - {canvasColorPickerOpen && ( - setCanvasColorPickerOpen(false)} - selectedColor={canvasColor} - onColorChange={setCanvasColor} - title={t( - "certSign.collab.signRequest.canvas.colorPickerTitle", - "Choose stroke colour", - )} - /> - )} - - -
- ); -} diff --git a/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx b/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx deleted file mode 100644 index fa2512fd04..0000000000 --- a/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx +++ /dev/null @@ -1,461 +0,0 @@ -import { useState, useRef, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { - Paper, - Group, - Button, - Text, - Divider, - CloseButton, -} from "@mantine/core"; -import { useIsPhone } from "@app/hooks/useIsMobile"; -import CancelIcon from "@mui/icons-material/Cancel"; -import FolderOpenIcon from "@mui/icons-material/FolderOpen"; -import ZoomInIcon from "@mui/icons-material/ZoomIn"; -import ZoomOutIcon from "@mui/icons-material/ZoomOut"; -import ZoomOutMapIcon from "@mui/icons-material/ZoomOutMap"; -import { LocalIcon } from "@app/components/shared/LocalIcon"; -import { Z_INDEX_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -import { SignRequestDetail } from "@app/types/signingSession"; -import { - LocalEmbedPDFWithAnnotations, - AnnotationAPI, -} from "@app/components/viewer/LocalEmbedPDFWithAnnotations"; -import { alert } from "@app/components/toast"; -import SignControlsStrip from "@app/components/tools/certSign/SignControlsStrip"; -import { CertificateConfigModal } from "@app/components/tools/certSign/modals/CertificateConfigModal"; -import type { CertificateSubmitData } from "@app/components/tools/certSign/modals/CertificateConfigModal"; -import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; -import { useFileActions } from "@app/contexts/file/fileHooks"; - -export interface SignRequestWorkbenchData { - signRequest: SignRequestDetail; - pdfFile: File; - onSign: (certificateData: FormData) => Promise; - onDecline: () => Promise; - onBack: () => void; - canSign: boolean; -} - -interface SignRequestWorkbenchViewProps { - data: SignRequestWorkbenchData; -} - -const SignRequestWorkbenchView = ({ data }: SignRequestWorkbenchViewProps) => { - const { t } = useTranslation(); - const isPhone = useIsPhone(); - const { signRequest, pdfFile, onSign, onDecline, onBack, canSign } = data; - const { actions: fileActions } = useFileActions(); - - // Ref for annotation API - const annotationApiRef = useRef(null); - - // Signature state - start with default config if user can sign - const [signatureConfig, setSignatureConfig] = useState( - canSign - ? { - signatureType: "canvas", - signerName: "", - fontFamily: "Helvetica", - fontSize: 16, - textColor: "#000000", - } - : null, - ); - const [previewCount, setPreviewCount] = useState(0); - const [placementMode, setPlacementMode] = useState(true); - const [hasSelectedAnnotation, setHasSelectedAnnotation] = useState(false); - - // Certificate modal state - const [certificateModalOpen, setCertificateModalOpen] = useState(false); - - // Process state - const [signing, setSigning] = useState(false); - const [declining, setDeclining] = useState(false); - - // Show/hide sign controls strip - always visible when user can sign - const signControlsVisible = canSign && signatureConfig !== null; - - // Check for selected annotation periodically - useEffect(() => { - if (!signControlsVisible || !annotationApiRef.current) { - setHasSelectedAnnotation(false); - return; - } - const check = () => { - const has = ( - annotationApiRef.current as any - )?.getHasSelectedAnnotation?.(); - setHasSelectedAnnotation(Boolean(has)); - }; - check(); - const id = setInterval(check, 350); - return () => clearInterval(id); - }, [signControlsVisible]); - - const handleSignatureSelected = (config: SignParameters) => { - setSignatureConfig(config); - }; - - const handleOpenCertificateModal = () => { - if (previewCount === 0) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.signRequest.noSignatures", - "Please place at least one signature on the PDF", - ), - }); - return; - } - setCertificateModalOpen(true); - }; - - const handleSign = async ( - certData: CertificateSubmitData, - reason?: string, - location?: string, - ) => { - const previews = annotationApiRef.current?.getSignaturePreviews() || []; - console.log("handleSign called, previews:", previews.length, "signatures"); - - setSigning(true); - try { - const formData = new FormData(); - - if (certData.certType === "UPLOAD") { - const { - uploadFormat, - p12File, - privateKeyFile, - certFile, - jksFile, - password, - } = certData; - formData.append("certType", uploadFormat); - switch (uploadFormat) { - case "PKCS12": - case "PFX": - if (!p12File) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.signRequest.noCertificate", - "Please select a certificate file", - ), - }); - setSigning(false); - return; - } - formData.append("p12File", p12File); - break; - case "PEM": - if (!privateKeyFile || !certFile) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.signRequest.noCertificate", - "Please select a certificate file", - ), - }); - setSigning(false); - return; - } - formData.append("privateKeyFile", privateKeyFile); - formData.append("certFile", certFile); - break; - case "JKS": - if (!jksFile) { - alert({ - alertType: "error", - title: t("common.error"), - body: t( - "certSign.collab.signRequest.noCertificate", - "Please select a certificate file", - ), - }); - setSigning(false); - return; - } - formData.append("jksFile", jksFile); - break; - } - if (password) { - formData.append("password", password); - } - } else { - formData.append("certType", certData.certType); - } - - // Add signature appearance settings from sign request - if (signRequest.showSignature !== undefined) { - formData.append("showSignature", signRequest.showSignature.toString()); - } - if ( - signRequest.pageNumber !== undefined && - signRequest.pageNumber !== null - ) { - formData.append("pageNumber", signRequest.pageNumber.toString()); - } - - // Participant-provided reason/location override session defaults - if (reason && reason.trim()) { - formData.append("reason", reason); - } else if (signRequest.reason) { - formData.append("reason", signRequest.reason); - } - - if (location && location.trim()) { - formData.append("location", location); - } else if (signRequest.location) { - formData.append("location", signRequest.location); - } - - if (signRequest.showLogo !== undefined) { - formData.append("showLogo", signRequest.showLogo.toString()); - } - - // Add all wet signatures from previews - if (previews.length > 0) { - const wetSignaturesJson = previews.map((preview) => ({ - type: preview.signatureType, - data: preview.signatureData, - page: preview.pageIndex, - x: preview.x, - y: preview.y, - width: preview.width, - height: preview.height, - })); - - console.log( - "Sending wet signatures to backend:", - wetSignaturesJson.length, - "signatures", - ); - formData.append("wetSignaturesData", JSON.stringify(wetSignaturesJson)); - } - - await onSign(formData); - setCertificateModalOpen(false); - } catch (error) { - console.error("Failed to sign document:", error); - } finally { - setSigning(false); - } - }; - - const handleDecline = async () => { - setDeclining(true); - try { - await onDecline(); - } catch (error) { - console.error("Failed to decline request:", error); - setDeclining(false); - } - }; - - const handleAddToActiveFiles = async () => { - await fileActions.addFiles([pdfFile], { skipUploadTracking: true }); - alert({ - alertType: "success", - title: t("success"), - body: t( - "certSign.collab.signRequest.addedToFiles", - "Document added to active files", - ), - expandable: false, - durationMs: 2500, - }); - onBack(); - }; - - const handleDeleteSelected = () => { - (annotationApiRef.current as any)?.deleteSelectedAnnotation?.(); - }; - - const handlePlaceSignature = ( - id: string, - pageIndex: number, - x: number, - y: number, - width: number, - height: number, - ) => { - console.log("Signature placed:", { id, pageIndex, x, y, width, height }); - }; - - return ( -
- {/* Top Control Bar */} - - - - -
- - {signRequest.documentName} - - {!isPhone && ( - - {t("certSign.collab.signRequest.from", "From")}:{" "} - {signRequest.ownerUsername} •{" "} - {new Date(signRequest.createdAt).toLocaleDateString()} - - )} -
-
- - - - {signRequest.myStatus !== "SIGNED" && - signRequest.myStatus !== "DECLINED" && ( - - )} - {!isPhone && ( - <> - - - - - - - - )} - - - -
-
- - {/* Sign Controls Strip - always shown when user can sign */} - {canSign && signControlsVisible && ( - 0} - signatureConfig={signatureConfig} - hasSelectedAnnotation={hasSelectedAnnotation} - onDeleteSelected={handleDeleteSelected} - /> - )} - - {/* PDF Viewer (full width) */} -
- {}} - placementMode={placementMode} - signatureData={signatureConfig?.signatureData} - signatureType={signatureConfig?.signatureType} - onPlaceSignature={handlePlaceSignature} - onPreviewCountChange={setPreviewCount} - /> -
- - {/* Certificate Configuration Modal */} - {canSign && ( - setCertificateModalOpen(false)} - onSign={handleSign} - signatureCount={previewCount} - disabled={signing} - defaultReason={signRequest.reason || ""} - defaultLocation={signRequest.location || ""} - /> - )} -
- ); -}; - -export default SignRequestWorkbenchView; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx index 39c85d6ff7..51601cfaaf 100644 --- a/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx +++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx @@ -72,7 +72,7 @@ export const SessionActionsPanel: React.FC = ({ + + + + + {session.documentName} + + + {session.finalized + ? t("certSign.collab.sessionList.finalized", "Finalized") + : t("certSign.collab.sessionList.active", "Active")} + + + {(session.ownerEmail || session.createdAt) && ( + + {session.ownerEmail && + `${t("certSign.collab.sessionDetail.owner", "Owner")}: ${session.ownerEmail}`} + {session.ownerEmail && session.createdAt && " • "} + {session.createdAt && + new Date(session.createdAt).toLocaleDateString()} + + )} + + + + + {/* Participants — scrollable, bounded so actions stay visible */} +
+ +
+ + + + setAddParticipantsModalOpen(true)} + onFinalize={handleFinalize} + onLoadSignedPdf={handleLoadSignedPdf} + finalizing={finalizing} + loadingPdf={loadingPdf} + /> + + {!session.finalized && ( + + )} + + {/* Add Participants Modal */} + setAddParticipantsModalOpen(false)} + onSubmit={handleAddParticipants} + /> + + {/* Delete Confirmation Modal */} + setDeleteModalOpen(false)} + title={t( + "certSign.collab.sessionDetail.deleteSession", + "Delete Session", + )} + > + + + {t( + "certSign.collab.sessionDetail.deleteConfirm", + "Are you sure? This cannot be undone.", + )} + + + + + + + + + ); +}; + +export default SessionDetailPanel; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx new file mode 100644 index 0000000000..7945c697f4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx @@ -0,0 +1,532 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActionIcon, + Box, + Button, + Group, + Menu, + Modal, + SegmentedControl, + Stack, + Text, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import DrawIcon from "@mui/icons-material/Draw"; +import OpenWithIcon from "@mui/icons-material/OpenWith"; +import CloseIcon from "@mui/icons-material/Close"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import CheckIcon from "@mui/icons-material/Check"; +import AddIcon from "@mui/icons-material/Add"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; + +import { + DEFAULT_PARAMETERS, + type SignParameters, +} from "@app/hooks/tools/sign/useSignParameters"; +import { + useSavedSignatures, + type SavedSignature, +} from "@app/hooks/tools/sign/useSavedSignatures"; +import { SignatureCreationStep } from "@app/components/tools/certSign/steps/SignatureCreationStep"; +import { type SignatureType } from "@app/components/shared/wetSignature/SignatureTypeSelector"; + +interface SignControlsPanelProps { + placementMode: boolean; + onPlacementModeChange: (active: boolean) => void; + onSignatureSelected: (config: SignParameters) => void; + onComplete: () => void; + canComplete: boolean; + signatureConfig: SignParameters | null; + hasSelectedAnnotation?: boolean; + onDeleteSelected?: () => void; +} + +// wetSignature creation type ↔ stored/placement signature type. +const STORED_TYPE: Record = { + draw: "canvas", + upload: "image", + type: "text", +}; + +/** Vertical sidebar signing controls: pick/create a signature, toggle place/move, delete, and complete & sign (placement happens on the main Viewer). */ +export default function SignControlsPanel({ + placementMode, + onPlacementModeChange, + onSignatureSelected, + onComplete, + canComplete, + signatureConfig, + hasSelectedAnnotation = false, + onDeleteSelected, +}: SignControlsPanelProps) { + const { t } = useTranslation(); + const { + savedSignatures, + addSignature, + removeSignature, + isAtCapacity, + byTypeCounts, + } = useSavedSignatures(); + + // Create-signature modal state (reuses the shared wet-signature creation flow). + const [createOpen, setCreateOpen] = useState(false); + const [createType, setCreateType] = useState("draw"); + const [createSignature, setCreateSignature] = useState(null); + const [textValue, setTextValue] = useState(""); + const [fontFamily, setFontFamily] = useState( + DEFAULT_PARAMETERS.fontFamily ?? "Helvetica", + ); + const [fontSize, setFontSize] = useState(DEFAULT_PARAMETERS.fontSize ?? 16); + const [textColor, setTextColor] = useState( + DEFAULT_PARAMETERS.textColor ?? "#000000", + ); + + const renderSavedSignaturePreview = useCallback( + (sig: SavedSignature) => { + if (sig.type === "text") { + return ( + + + {sig.signerName} + + + ); + } + + return ( + + + + ); + }, + [t], + ); + + const sortedSavedSignatures = useMemo(() => { + if (!savedSignatures.length) return []; + return [...savedSignatures].sort( + (a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt), + ); + }, [savedSignatures]); + + const beginPlacement = useCallback( + (config: SignParameters) => { + onSignatureSelected({ ...DEFAULT_PARAMETERS, ...config }); + onPlacementModeChange(true); + }, + [onSignatureSelected, onPlacementModeChange], + ); + + // Auto-select the most recent saved signature on first open. + const hasAutoSelected = useRef(false); + useEffect(() => { + if (hasAutoSelected.current) return; + if (!sortedSavedSignatures.length) return; + if (signatureConfig?.signatureData) return; + + hasAutoSelected.current = true; + const lastSig = sortedSavedSignatures[0]; + if (lastSig.type === "text") { + onSignatureSelected({ + ...DEFAULT_PARAMETERS, + signatureType: "text", + signerName: lastSig.signerName, + fontFamily: lastSig.fontFamily, + fontSize: lastSig.fontSize, + textColor: lastSig.textColor, + signatureData: lastSig.dataUrl, + }); + } else { + onSignatureSelected({ + ...DEFAULT_PARAMETERS, + signatureType: lastSig.type, + signatureData: lastSig.dataUrl, + }); + } + }, [ + sortedSavedSignatures, + signatureConfig?.signatureData, + onSignatureSelected, + ]); + + const applySavedSignature = useCallback( + (sig: SavedSignature) => { + if (sig.type === "text") { + beginPlacement({ + signatureType: "text", + signerName: sig.signerName, + fontFamily: sig.fontFamily, + fontSize: sig.fontSize, + textColor: sig.textColor, + signatureData: sig.dataUrl, + }); + return; + } + beginPlacement({ signatureType: sig.type, signatureData: sig.dataUrl }); + }, + [beginPlacement], + ); + + const openCreateModal = useCallback(() => { + setCreateType("draw"); + setCreateSignature(null); + setTextValue(""); + setCreateOpen(true); + }, []); + + // Save the freshly created signature to the library, then begin placing it. + const handleUseCreated = useCallback(async () => { + if (!createSignature) return; + const storedType = STORED_TYPE[createType]; + const isText = storedType === "text"; + + if (!isAtCapacity) { + const index = (byTypeCounts?.[storedType] ?? 0) + 1; + const baseLabel = isText + ? t( + "certSign.collab.signRequest.saved.defaultTextLabel", + "Typed signature", + ) + : storedType === "image" + ? t( + "certSign.collab.signRequest.saved.defaultImageLabel", + "Uploaded signature", + ) + : t( + "certSign.collab.signRequest.saved.defaultCanvasLabel", + "Drawing signature", + ); + await addSignature( + isText + ? { + type: "text", + dataUrl: createSignature, + signerName: textValue, + fontFamily, + fontSize, + textColor, + } + : { type: storedType, dataUrl: createSignature }, + `${baseLabel} ${index}`, + "localStorage", + ); + } + + beginPlacement( + isText + ? { + signatureType: "text", + signatureData: createSignature, + signerName: textValue, + fontFamily, + fontSize, + textColor, + } + : { signatureType: storedType, signatureData: createSignature }, + ); + setCreateOpen(false); + }, [ + createSignature, + createType, + isAtCapacity, + byTypeCounts, + t, + addSignature, + beginPlacement, + textValue, + fontFamily, + fontSize, + textColor, + ]); + + // Keyboard: Esc pauses placement, Backspace deletes the selected placement. + useEffect(() => { + if (!signatureConfig || createOpen) return; + + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const isTypingTarget = + target?.tagName === "INPUT" || + target?.tagName === "TEXTAREA" || + target?.tagName === "CANVAS" || + (target as { isContentEditable?: boolean })?.isContentEditable; + if (isTypingTarget) return; + + if (event.key === "Escape") { + onPlacementModeChange(false); + return; + } + if (event.key === "Backspace") { + event.preventDefault(); + onDeleteSelected?.(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onPlacementModeChange, onDeleteSelected, signatureConfig, createOpen]); + + if (!signatureConfig) return null; + + const previewNode = + signatureConfig.signatureType === "text" ? ( + + {(signatureConfig.signerName ?? "").trim() || + t("certSign.collab.signRequest.preview.textFallback", "Signature")} + + ) : signatureConfig.signatureData ? ( + {t( + ) : ( + + + + {t("certSign.collab.signRequest.preview.create", "Add signature")} + + + ); + + return ( + + + {t("certSign.collab.signRequest.signingTitle", "Signing")} + + + {/* Current signature + change menu */} + + + + + + {sortedSavedSignatures.length ? ( + sortedSavedSignatures.map((sig) => ( + applySavedSignature(sig)}> + + {renderSavedSignaturePreview(sig)} + { + e.stopPropagation(); + removeSignature(sig.id); + }} + aria-label={t( + "certSign.collab.signRequest.saved.delete", + "Delete signature", + )} + > + + + + + )) + ) : ( + + {t( + "certSign.collab.signRequest.saved.none", + "No saved signatures", + )} + + )} + + } + onClick={openCreateModal} + disabled={isAtCapacity} + > + {t( + "certSign.collab.signRequest.createNewSignature", + "Create New Signature", + )} + + + + + {/* Place vs. move */} + onPlacementModeChange(value === "place")} + data={[ + { + value: "place", + label: ( + + + + {t("certSign.collab.signRequest.mode.place", "Place")} + + + ), + }, + { + value: "move", + label: ( + + + + {t("certSign.collab.signRequest.mode.move", "Move")} + + + ), + }, + ]} + size="xs" + radius="xl" + aria-label={t( + "certSign.collab.signRequest.mode.title", + "Sign or move mode", + )} + /> + + + + + + {/* Create signature — reuses the shared wet-signature creation flow */} + setCreateOpen(false)} + title={t( + "certSign.collab.signRequest.createNewSignature", + "Create New Signature", + )} + size="md" + withinPortal + > + + + + ); +} diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx new file mode 100644 index 0000000000..7a5c021de8 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx @@ -0,0 +1,357 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Stack, Button, Text, Divider } from "@mantine/core"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import CancelIcon from "@mui/icons-material/Cancel"; +import FolderOpenIcon from "@mui/icons-material/FolderOpen"; +import { alert } from "@app/components/toast"; +import type { + SignatureOverlayAPI, + SignaturePreview, +} from "@app/components/viewer/viewerTypes"; +import { useSigningOverlay } from "@app/contexts/SigningOverlayContext"; +import { useFileActions } from "@app/contexts/file/fileHooks"; +import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; +import SignControlsPanel from "@app/components/tools/certSign/panels/SignControlsPanel"; +import { CertificateConfigModal } from "@app/components/tools/certSign/modals/CertificateConfigModal"; +import type { CertificateSubmitData } from "@app/components/tools/certSign/modals/CertificateConfigModal"; +import type { SigningRequestData } from "@app/hooks/signing/useSigningSessionController"; + +interface SignRequestPanelProps { + data: SigningRequestData; +} + +/** Sidebar controls for a sign request: drives viewer placement via the overlay context and reads placed signatures via the overlay API ref. */ +const SignRequestPanel = ({ data }: SignRequestPanelProps) => { + const { t } = useTranslation(); + const { signRequest, pdfFile, onSign, onDecline, onBack, canSign } = data; + const { actions: fileActions } = useFileActions(); + const { setOverlay } = useSigningOverlay(); + + // Imperative handle to the viewer's signature overlay layer. + const overlayApiRef = useRef(null); + + const [signatureConfig, setSignatureConfig] = useState( + canSign + ? { + signatureType: "canvas", + signerName: "", + fontFamily: "Helvetica", + fontSize: 16, + textColor: "#000000", + } + : null, + ); + const [previewCount, setPreviewCount] = useState(0); + const [placementMode, setPlacementMode] = useState(true); + const [hasSelectedAnnotation, setHasSelectedAnnotation] = useState(false); + const [certificateModalOpen, setCertificateModalOpen] = useState(false); + const [signing, setSigning] = useState(false); + const [declining, setDeclining] = useState(false); + + const signControlsVisible = canSign && signatureConfig !== null; + + const handlePreviewsChange = useCallback((previews: SignaturePreview[]) => { + setPreviewCount(previews.length); + }, []); + + // Drive the shared viewer: show the document and (when the user can sign) + // enable interactive placement of the selected signature. + const placementData = signatureConfig?.signatureData; + const placementType = signatureConfig?.signatureType; + useEffect(() => { + setOverlay({ + file: pdfFile, + signaturePlacementMode: signControlsVisible ? placementMode : false, + signaturePlacementData: signControlsVisible ? placementData : undefined, + signaturePlacementType: signControlsVisible ? placementType : undefined, + onSignaturePreviewsChange: handlePreviewsChange, + signatureOverlayApiRef: overlayApiRef, + }); + }, [ + pdfFile, + signControlsVisible, + placementMode, + placementData, + placementType, + handlePreviewsChange, + setOverlay, + ]); + + // Clear the shared viewer overlay when leaving the sign request. + useEffect(() => { + return () => setOverlay(null); + }, [setOverlay]); + + // Poll for a selected placement (drives the delete control). + useEffect(() => { + if (!signControlsVisible) { + setHasSelectedAnnotation(false); + return; + } + const check = () => + setHasSelectedAnnotation(Boolean(overlayApiRef.current?.hasSelected?.())); + check(); + const id = setInterval(check, 350); + return () => clearInterval(id); + }, [signControlsVisible]); + + const handleOpenCertificateModal = () => { + if (previewCount === 0) { + alert({ + alertType: "error", + title: t("common.error"), + body: t( + "certSign.collab.signRequest.noSignatures", + "Please place at least one signature on the PDF", + ), + }); + return; + } + setCertificateModalOpen(true); + }; + + const handleSign = async ( + certData: CertificateSubmitData, + reason?: string, + location?: string, + ) => { + const previews = overlayApiRef.current?.getSignaturePreviews() || []; + + setSigning(true); + try { + const formData = new FormData(); + + if (certData.certType === "UPLOAD") { + const { + uploadFormat, + p12File, + privateKeyFile, + certFile, + jksFile, + password, + } = certData; + formData.append("certType", uploadFormat); + switch (uploadFormat) { + case "PKCS12": + case "PFX": + if (!p12File) { + alert({ + alertType: "error", + title: t("common.error"), + body: t( + "certSign.collab.signRequest.noCertificate", + "Please select a certificate file", + ), + }); + setSigning(false); + return; + } + formData.append("p12File", p12File); + break; + case "PEM": + if (!privateKeyFile || !certFile) { + alert({ + alertType: "error", + title: t("common.error"), + body: t( + "certSign.collab.signRequest.noCertificate", + "Please select a certificate file", + ), + }); + setSigning(false); + return; + } + formData.append("privateKeyFile", privateKeyFile); + formData.append("certFile", certFile); + break; + case "JKS": + if (!jksFile) { + alert({ + alertType: "error", + title: t("common.error"), + body: t( + "certSign.collab.signRequest.noCertificate", + "Please select a certificate file", + ), + }); + setSigning(false); + return; + } + formData.append("jksFile", jksFile); + break; + } + if (password) { + formData.append("password", password); + } + } else { + formData.append("certType", certData.certType); + } + + // Signature appearance settings from the sign request + if (signRequest.showSignature !== undefined) { + formData.append("showSignature", signRequest.showSignature.toString()); + } + if ( + signRequest.pageNumber !== undefined && + signRequest.pageNumber !== null + ) { + formData.append("pageNumber", signRequest.pageNumber.toString()); + } + + // Participant-provided reason/location override session defaults + if (reason && reason.trim()) { + formData.append("reason", reason); + } else if (signRequest.reason) { + formData.append("reason", signRequest.reason); + } + + if (location && location.trim()) { + formData.append("location", location); + } else if (signRequest.location) { + formData.append("location", signRequest.location); + } + + if (signRequest.showLogo !== undefined) { + formData.append("showLogo", signRequest.showLogo.toString()); + } + + // All placed wet signatures (coordinates are page fractions) + if (previews.length > 0) { + const wetSignaturesJson = previews.map((preview) => ({ + type: preview.signatureType, + data: preview.signatureData, + page: preview.pageIndex, + x: preview.x, + y: preview.y, + width: preview.width, + height: preview.height, + })); + formData.append("wetSignaturesData", JSON.stringify(wetSignaturesJson)); + } + + await onSign(formData); + setCertificateModalOpen(false); + } catch (error) { + console.error("Failed to sign document:", error); + } finally { + setSigning(false); + } + }; + + const handleDecline = async () => { + setDeclining(true); + try { + await onDecline(); + } catch (error) { + console.error("Failed to decline request:", error); + setDeclining(false); + } + }; + + const handleAddToActiveFiles = async () => { + await fileActions.addFiles([pdfFile], { skipUploadTracking: true }); + alert({ + alertType: "success", + title: t("success"), + body: t( + "certSign.collab.signRequest.addedToFiles", + "Document added to active files", + ), + expandable: false, + durationMs: 2500, + }); + onBack(); + }; + + const handleDeleteSelected = () => { + overlayApiRef.current?.deleteSelected?.(); + }; + + return ( + + + + + + {signRequest.documentName} + + + {t("certSign.collab.signRequest.from", "From")}:{" "} + {signRequest.ownerUsername} •{" "} + {new Date(signRequest.createdAt).toLocaleDateString()} + + + + + + {canSign && signControlsVisible && ( + <> + 0} + signatureConfig={signatureConfig} + hasSelectedAnnotation={hasSelectedAnnotation} + onDeleteSelected={handleDeleteSelected} + /> + + + )} + + + + {signRequest.myStatus !== "SIGNED" && + signRequest.myStatus !== "DECLINED" && ( + + )} + + {canSign && ( + setCertificateModalOpen(false)} + onSign={handleSign} + signatureCount={previewCount} + disabled={signing} + defaultReason={signRequest.reason || ""} + defaultLocation={signRequest.location || ""} + /> + )} + + ); +}; + +export default SignRequestPanel; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx index 71a3502659..7f1ed24125 100644 --- a/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx +++ b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx @@ -23,6 +23,8 @@ interface SignatureCreationStepProps { onFontSizeChange: (size: number) => void; onTextColorChange: (color: string) => void; onNext: () => void; + /** Label for the confirm button; defaults to the cert-flow "continue" text. */ + nextLabel?: string; disabled?: boolean; } @@ -40,6 +42,7 @@ export const SignatureCreationStep: React.FC = ({ onFontSizeChange, onTextColorChange, onNext, + nextLabel, disabled = false, }) => { const { t } = useTranslation(); @@ -89,10 +92,11 @@ export const SignatureCreationStep: React.FC = ({ )} ); diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index bdaa473935..de7a410b0d 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -13,7 +13,6 @@ import { Box, } from "@mantine/core"; import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; -import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection"; import { useSignature } from "@app/contexts/SignatureContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { @@ -1233,8 +1232,6 @@ const SignSettings = ({ {translate("applySignatures", "Apply Signatures")} )} - - ); }; diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index fbea209842..e184ae6d58 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -35,6 +35,7 @@ interface ToolButtonProps { showDescription?: boolean; /** Called when an unavailable tool is clicked; if provided, overrides the default no-op */ onUnavailableClick?: () => void; + badgeCount?: number; } const ToolButton: React.FC = ({ @@ -47,6 +48,7 @@ const ToolButton: React.FC = ({ hasStars = false, showDescription = false, onUnavailableClick, + badgeCount, }) => { const { t } = useTranslation(); const { config } = useAppConfig(); @@ -183,6 +185,16 @@ const ToolButton: React.FC = ({ {t("toolPanel.alpha", "Alpha")} )} + {typeof badgeCount === "number" && badgeCount > 0 && ( + + {badgeCount} + + )} {usesCloud && !visuallyUnavailable && }
{showDescription && tool.description && ( diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 669b967091..24bc42b02e 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -26,6 +26,10 @@ import { import { useSignature } from "@app/contexts/SignatureContext"; import { useRedaction } from "@app/contexts/RedactionContext"; import type { RedactionPendingTrackerAPI } from "@app/components/viewer/RedactionPendingTracker"; +import type { + SignaturePreview, + SignatureOverlayAPI, +} from "@app/components/viewer/viewerTypes"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext"; import { useViewerWorkbenchBarButtons } from "@app/components/viewer/useViewerWorkbenchBarButtons"; @@ -141,6 +145,14 @@ export interface EmbedPdfViewerProps { setSidebarsVisible: (v: boolean) => void; onClose?: () => void; previewFile?: File | null; + // ── Signature overlay pass-through (opt-in; all default off) ────────────── + signaturePreviews?: SignaturePreview[]; + signaturePreviewsReadOnly?: boolean; + signaturePlacementMode?: boolean; + signaturePlacementData?: string; + signaturePlacementType?: "canvas" | "image" | "text"; + onSignaturePreviewsChange?: (previews: SignaturePreview[]) => void; + signatureOverlayApiRef?: React.RefObject; } const EmbedPdfViewerContent = ({ @@ -148,6 +160,13 @@ const EmbedPdfViewerContent = ({ setSidebarsVisible: _setSidebarsVisible, onClose, previewFile, + signaturePreviews, + signaturePreviewsReadOnly, + signaturePlacementMode, + signaturePlacementData, + signaturePlacementType, + onSignaturePreviewsChange, + signatureOverlayApiRef, }: EmbedPdfViewerProps) => { const { t } = useTranslation(); const viewerRef = React.useRef(null); @@ -1301,6 +1320,13 @@ const EmbedPdfViewerContent = ({ // Handle signature added - for debugging, enable console logs as needed // Future: Handle signature completion }} + signaturePreviews={signaturePreviews} + signaturePreviewsReadOnly={signaturePreviewsReadOnly} + signaturePlacementMode={signaturePlacementMode} + signaturePlacementData={signaturePlacementData} + signaturePlacementType={signaturePlacementType} + onSignaturePreviewsChange={onSignaturePreviewsChange} + signatureOverlayApiRef={signatureOverlayApiRef} /> {/* Floating save bar for form-filled PDFs (like Chrome/Firefox PDF viewers) */} void; + /** Imperative handle for reading/clearing/deleting signature previews. */ + signatureOverlayApiRef?: React.RefObject; } export function LocalEmbedPDF({ @@ -142,6 +166,13 @@ export function LocalEmbedPDF({ commentsSidebarRightOffset = "0rem", isSignMode = false, pdfRenderMode = "normal", + signaturePreviews, + signaturePreviewsReadOnly = false, + signaturePlacementMode = false, + signaturePlacementData, + signaturePlacementType, + onSignaturePreviewsChange, + signatureOverlayApiRef, }: LocalEmbedPDFProps) { const { t } = useTranslation(); const { config } = useAppConfig(); @@ -151,6 +182,58 @@ export function LocalEmbedPDF({ >([]); const [commentAuthorName, setCommentAuthorName] = useState("Guest"); + const [localSignaturePreviews, setLocalSignaturePreviews] = useState< + SignaturePreview[] + >(signaturePreviews ?? []); + + // Mount the overlay for controlled previews, placement mode, or once any + // signature is placed — so leaving placement mode doesn't hide placements. + const signatureOverlayEnabled = + signaturePreviews !== undefined || + signaturePlacementMode || + localSignaturePreviews.length > 0; + const [selectedSignatureId, setSelectedSignatureId] = useState( + null, + ); + + // Keep internal state in sync when the caller supplies controlled previews. + useEffect(() => { + if (signaturePreviews !== undefined) { + setLocalSignaturePreviews(signaturePreviews); + } + }, [signaturePreviews]); + + const handleSignaturePreviewsChange = useCallback( + (next: SignaturePreview[]) => { + setLocalSignaturePreviews(next); + onSignaturePreviewsChange?.(next); + }, + [onSignaturePreviewsChange], + ); + + useImperativeHandle( + signatureOverlayApiRef, + () => ({ + getSignaturePreviews: () => localSignaturePreviews, + clearPreviews: () => { + setLocalSignaturePreviews([]); + setSelectedSignatureId(null); + onSignaturePreviewsChange?.([]); + }, + deleteSelected: () => { + if (!selectedSignatureId) return; + const next = localSignaturePreviews.filter( + (p) => p.id !== selectedSignatureId, + ); + setSelectedSignatureId(null); + setLocalSignaturePreviews(next); + onSignaturePreviewsChange?.(next); + }, + hasSelected: () => selectedSignatureId !== null, + }), + [localSignaturePreviews, selectedSignatureId, onSignaturePreviewsChange], + ); + useEffect(() => { if (!config?.enableLogin) return; accountService @@ -1073,6 +1156,23 @@ export function LocalEmbedPDF({ documentId={documentId} pageIndex={pageIndex} /> + + {/* Signature preview overlay (opt-in; off by default) */} + {signatureOverlayEnabled && ( + + )}
diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDFWithAnnotations.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDFWithAnnotations.tsx deleted file mode 100644 index 25dcf74461..0000000000 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDFWithAnnotations.tsx +++ /dev/null @@ -1,1006 +0,0 @@ -import { - useEffect, - useMemo, - useState, - useImperativeHandle, - forwardRef, - useRef, -} from "react"; -import { useTranslation } from "react-i18next"; -import { createPluginRegistration } from "@embedpdf/core"; -import type { PluginRegistry } from "@embedpdf/core"; -import { EmbedPDF } from "@embedpdf/core/react"; -import { usePdfiumEngine } from "@embedpdf/engines/react"; - -// Import the essential plugins -import { - Viewport, - ViewportPluginPackage, -} from "@embedpdf/plugin-viewport/react"; -import { Scroller, ScrollPluginPackage } from "@embedpdf/plugin-scroll/react"; -import { DocumentManagerPluginPackage } from "@embedpdf/plugin-document-manager/react"; -import { RenderPluginPackage } from "@embedpdf/plugin-render/react"; -import { ZoomPluginPackage, ZoomMode } from "@embedpdf/plugin-zoom/react"; -import { - InteractionManagerPluginPackage, - PagePointerProvider, - GlobalPointerProvider, - useInteractionManagerCapability, -} from "@embedpdf/plugin-interaction-manager/react"; -import { - SelectionLayer, - SelectionPluginPackage, -} from "@embedpdf/plugin-selection/react"; -import { - TilingLayer, - TilingPluginPackage, -} from "@embedpdf/plugin-tiling/react"; -import { PanPluginPackage } from "@embedpdf/plugin-pan/react"; -import { SpreadPluginPackage, SpreadMode } from "@embedpdf/plugin-spread/react"; -import { SearchPluginPackage } from "@embedpdf/plugin-search/react"; -import { ThumbnailPluginPackage } from "@embedpdf/plugin-thumbnail/react"; -import { RotatePluginPackage, Rotate } from "@embedpdf/plugin-rotate/react"; -import { Rotation, PdfAnnotationSubtype } from "@embedpdf/models"; - -// Import annotation plugins -import { HistoryPluginPackage } from "@embedpdf/plugin-history/react"; -import { - AnnotationLayer, - AnnotationPluginPackage, -} from "@embedpdf/plugin-annotation/react"; - -import { CustomSearchLayer } from "@app/components/viewer/CustomSearchLayer"; -import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback"; -import { ActionIcon, Center, Stack, Text, Tooltip } from "@mantine/core"; -import CloseIcon from "@mui/icons-material/Close"; -import { ScrollAPIBridge } from "@app/components/viewer/ScrollAPIBridge"; -import { SelectionAPIBridge } from "@app/components/viewer/SelectionAPIBridge"; -import { PanAPIBridge } from "@app/components/viewer/PanAPIBridge"; -import { SpreadAPIBridge } from "@app/components/viewer/SpreadAPIBridge"; -import { SearchAPIBridge } from "@app/components/viewer/SearchAPIBridge"; -import { ThumbnailAPIBridge } from "@app/components/viewer/ThumbnailAPIBridge"; -import { RotateAPIBridge } from "@app/components/viewer/RotateAPIBridge"; -import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrapper"; -import { - Z_INDEX_SIGNATURE_OVERLAY, - Z_INDEX_SIGNATURE_OVERLAY_DELETE, - Z_INDEX_SIGNATURE_OVERLAY_HANDLE, -} from "@app/styles/zIndex"; - -/** Rendered inside EmbedPDF context; exposes interaction manager pause/resume via ref. */ -function InteractionPauseBridge({ - bridgeRef, -}: { - bridgeRef: React.MutableRefObject<{ - pause: () => void; - resume: () => void; - } | null>; -}) { - const { provides } = useInteractionManagerCapability(); - useEffect(() => { - if (provides) { - bridgeRef.current = { - pause: () => provides.pause(), - resume: () => provides.resume(), - }; - } - return () => { - bridgeRef.current = null; - }; - }, [provides, bridgeRef]); - return null; -} - -const DOCUMENT_NAME = "stirling-pdf-signing-viewer"; - -export interface SignaturePreview { - id: string; - pageIndex: number; - x: number; - y: number; - width: number; - height: number; - signatureData: string; // Base64 PNG image - signatureType: "canvas" | "image" | "text"; - color?: string; // Per-participant color (rgb(...) string); falls back to default blue - participantName?: string; // Shown in tooltip on hover -} - -interface LocalEmbedPDFWithAnnotationsProps { - file?: File | Blob; - url?: string | null; - onAnnotationChange?: (annotations: SignaturePreview[]) => void; - placementMode?: boolean; - signatureData?: string; - signatureType?: "canvas" | "image" | "text"; - onPlaceSignature?: ( - id: string, - pageIndex: number, - x: number, - y: number, - width: number, - height: number, - ) => void; - onPreviewCountChange?: (count: number) => void; - initialSignatures?: SignaturePreview[]; // Initial signatures to display (read-only preview) - readOnly?: boolean; // If true, signature previews cannot be moved or deleted -} - -export interface AnnotationAPI { - setActiveTool: (toolId: string | null) => void; - setToolDefaults: (toolId: string, defaults: any) => void; - getActiveTool: () => any; - getPageAnnotations: (pageIndex: number) => Promise; - getAllAnnotations: () => Promise; - getSignaturePreviews: () => SignaturePreview[]; - clearPreviews: () => void; - zoomIn: () => void; - zoomOut: () => void; - resetZoom: () => void; -} - -export const LocalEmbedPDFWithAnnotations = forwardRef< - AnnotationAPI | null, - LocalEmbedPDFWithAnnotationsProps ->( - ( - { - file, - url, - onAnnotationChange, - placementMode = false, - signatureData, - signatureType, - onPlaceSignature, - onPreviewCountChange, - initialSignatures = [], - readOnly = false, - }, - ref, - ) => { - const { t } = useTranslation(); - const [pdfUrl, setPdfUrl] = useState(null); - const annotationApiRef = useRef(null); - const zoomApiRef = useRef(null); - const containerRef = useRef(null); - - // State for signature preview overlays (support multiple) - const [signaturePreviews, setSignaturePreviews] = - useState(initialSignatures); - - // Track if a drag operation just occurred to prevent click from firing - const isDraggingRef = useRef(false); - const interactionPauseRef = useRef<{ - pause: () => void; - resume: () => void; - } | null>(null); - - // Track cursor position over a specific page for hover preview - const [cursorOnPage, setCursorOnPage] = useState<{ - pageIndex: number; - x: number; - y: number; - } | null>(null); - - // Expose annotation API to parent - useImperativeHandle( - ref, - () => ({ - setActiveTool: (toolId: string | null) => { - annotationApiRef.current?.setActiveTool(toolId); - }, - setToolDefaults: (toolId: string, defaults: any) => { - annotationApiRef.current?.setToolDefaults(toolId, defaults); - }, - getActiveTool: () => { - return annotationApiRef.current?.getActiveTool(); - }, - getPageAnnotations: async (pageIndex: number) => { - if (!annotationApiRef.current?.getPageAnnotations) return []; - const task = annotationApiRef.current.getPageAnnotations({ - pageIndex, - }); - if (task?.toPromise) { - return await task.toPromise(); - } - return []; - }, - getAllAnnotations: async () => { - // Get all annotations across all pages - // Note: In practice, we'll use getPageAnnotations for the specific page - // where the user placed their signature, so this method is optional - if (!annotationApiRef.current?.getPageAnnotations) return []; - - // Would need document page count to iterate through all pages - // For signing workflow, we track annotations via onAnnotationChange callback instead - return []; - }, - getSignaturePreviews: () => { - return signaturePreviews; - }, - clearPreviews: () => { - setSignaturePreviews([]); - }, - zoomIn: () => { - zoomApiRef.current?.zoomIn(); - }, - zoomOut: () => { - zoomApiRef.current?.zoomOut(); - }, - resetZoom: () => { - zoomApiRef.current?.resetZoom(); - }, - }), - [signaturePreviews], - ); - - // Convert File to URL if needed - useEffect(() => { - if (file) { - const objectUrl = URL.createObjectURL(file); - setPdfUrl(objectUrl); - return () => URL.revokeObjectURL(objectUrl); - } else if (url) { - setPdfUrl(url); - } - }, [file, url]); - - // Notify parent when signature previews change - useEffect(() => { - if (onAnnotationChange) { - onAnnotationChange(signaturePreviews); - } - if (onPreviewCountChange) { - onPreviewCountChange(signaturePreviews.length); - } - }, [signaturePreviews, onAnnotationChange, onPreviewCountChange]); - - // Create plugins configuration with annotation support - const plugins = useMemo(() => { - if (!pdfUrl) return []; - - // Calculate 3.5rem in pixels dynamically based on root font size - const rootFontSize = parseFloat( - getComputedStyle(document.documentElement).fontSize, - ); - const viewportGap = rootFontSize * 3.5; - - return [ - createPluginRegistration(DocumentManagerPluginPackage, { - initialDocuments: [ - { - url: pdfUrl, - name: DOCUMENT_NAME, - }, - ], - }), - createPluginRegistration(ViewportPluginPackage, { - viewportGap, - }), - createPluginRegistration(ScrollPluginPackage), - createPluginRegistration(RenderPluginPackage, { - withForms: true, - withAnnotations: true, - }), - - // Register interaction manager (required for annotations) - createPluginRegistration(InteractionManagerPluginPackage), - - // Register selection plugin (depends on InteractionManager) - createPluginRegistration(SelectionPluginPackage), - - // Register history plugin for undo/redo (recommended for annotations) - createPluginRegistration(HistoryPluginPackage), - - // Register annotation plugin (depends on InteractionManager, Selection, History) - createPluginRegistration(AnnotationPluginPackage, { - annotationAuthor: "Digital Signature", - autoCommit: true, - deactivateToolAfterCreate: false, - selectAfterCreate: true, - }), - - // Register pan plugin. Keep the default mode ("never"). Do NOT set - // defaultMode: "mobile" - it makes pan the default interaction on any - // touch-capable device (e.g. Windows touchscreen laptops) and blocks - // text selection. - createPluginRegistration(PanPluginPackage), - - // Register zoom plugin - createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: ZoomMode.FitWidth, - minZoom: 0.2, - maxZoom: 3.0, - }), - - // Register tiling plugin - createPluginRegistration(TilingPluginPackage, { - tileSize: 768, - overlapPx: 5, - extraRings: 1, - }), - - // Register spread plugin - createPluginRegistration(SpreadPluginPackage, { - defaultSpreadMode: SpreadMode.None, - }), - - // Register search plugin - createPluginRegistration(SearchPluginPackage), - - // Register thumbnail plugin - createPluginRegistration(ThumbnailPluginPackage), - - // Register rotate plugin - createPluginRegistration(RotatePluginPackage, { - defaultRotation: Rotation.Degree0, - }), - ]; - }, [pdfUrl]); - - // Initialize the engine - const { engine, isLoading, error } = usePdfiumEngine(); - - // Early return if no file or URL provided - if (!file && !url) { - return ( -
- -
📄
- - No PDF provided - -
-
- ); - } - - if (isLoading || !engine || !pdfUrl) { - return ; - } - - if (error) { - return ( -
- -
- - Error loading PDF engine: {error.message} - -
-
- ); - } - - return ( -
- { - // v2.0: Use registry.getPlugin() to access plugin APIs - const annotationPlugin = registry.getPlugin("annotation"); - if (!annotationPlugin || !annotationPlugin.provides) return; - - const annotationApi = annotationPlugin.provides(); - if (!annotationApi) return; - - // Store reference for parent component access - annotationApiRef.current = annotationApi; - - // Add custom signature image tool - // Using FreeText with appearance for better image support - annotationApi.addTool({ - id: "signatureStamp", - name: "Digital Signature", - interaction: { exclusive: false, cursor: "crosshair" }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.STAMP, - // Image data will be set dynamically via setToolDefaults - width: 150, - height: 75, - }, - }); - - // Add custom ink signature tool - 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, - }, - }); - - // Wire zoom API so parent can call zoomIn/zoomOut/resetZoom - const zoomPlugin = registry.getPlugin("zoom"); - if (zoomPlugin?.provides) { - const zoomApi = zoomPlugin.provides(); - zoomApiRef.current = { - zoomIn: () => zoomApi.zoomIn?.(), - zoomOut: () => zoomApi.zoomOut?.(), - resetZoom: () => - zoomApi.requestZoom?.(ZoomMode.FitWidth, { vx: 0.5, vy: 0 }), - }; - } - }} - > - - - - - - - - - - - - } - > - {(documentId) => ( - - - ( - - -
e.preventDefault()} - onDrop={(e) => e.preventDefault()} - onDragOver={(e) => e.preventDefault()} - onMouseMove={(e) => { - if (!placementMode || !signatureData) return; - const rect = - e.currentTarget.getBoundingClientRect(); - setCursorOnPage({ - pageIndex, - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }); - }} - onMouseLeave={() => { - setCursorOnPage((prev) => - prev?.pageIndex === pageIndex ? null : prev, - ); - }} - onClick={(e) => { - if (isDraggingRef.current) return; - - if (placementMode && onPlaceSignature) { - const rect = - e.currentTarget.getBoundingClientRect(); - // Store as fractions (0–1) of the rendered page so overlays - // remain correct at any zoom level (scale not in new API) - const sigWidth = 150 / width; - const sigHeight = 75 / height; - const rawX = (e.clientX - rect.left) / width; - const rawY = (e.clientY - rect.top) / height; - const x = Math.max( - 0, - Math.min(rawX - sigWidth / 2, 1 - sigWidth), - ); - const y = Math.max( - 0, - Math.min(rawY - sigHeight / 2, 1 - sigHeight), - ); - - const newPreview = { - id: `sig-preview-${Date.now()}-${Math.random()}`, - pageIndex, - x, - y, - width: sigWidth, - height: sigHeight, - signatureData: signatureData || "", - signatureType: signatureType || "image", - }; - setSignaturePreviews((prev) => [ - ...prev, - newPreview, - ]); - onPlaceSignature( - newPreview.id, - pageIndex, - x * width, - y * height, - sigWidth * width, - sigHeight * height, - ); - } - }} - > - - - - - - - {/* Annotation layer for signatures */} - - - {/* Signature preview overlays (support multiple) */} - {signaturePreviews - .filter( - (preview) => preview.pageIndex === pageIndex, - ) - .map((preview) => { - if (!preview.signatureData) return null; - const color = - preview.color ?? "rgb(0, 122, 204)"; - const colorOpacity = (opacity: number) => - color.startsWith("rgb(") - ? color - .replace("rgb(", "rgba(") - .replace(")", `, ${opacity})`) - : color; - return ( - -
- {/* Delete button - only show when not read-only */} - {!readOnly && ( - { - e.stopPropagation(); - setSignaturePreviews((prev) => - prev.filter( - (p) => p.id !== preview.id, - ), - ); - }} - aria-label={t( - "viewer.signature.delete", - "Delete signature", - )} - > - - - )} - -
{ - if ( - (e.target as HTMLElement) - .dataset.resizeHandle - ) - return; - e.stopPropagation(); - e.preventDefault(); - const el = e.currentTarget; - el.setPointerCapture( - e.pointerId, - ); - interactionPauseRef.current?.pause(); - - const startX = e.clientX; - const startY = e.clientY; - const startLeft = preview.x; - const startTop = preview.y; - - const handlePointerMove = ( - moveEvent: PointerEvent, - ) => { - isDraggingRef.current = true; - const deltaX = - (moveEvent.clientX - - startX) / - width; - const deltaY = - (moveEvent.clientY - - startY) / - height; - setSignaturePreviews((prev) => - prev.map((p) => - p.id === preview.id - ? { - ...p, - x: - startLeft + - deltaX, - y: - startTop + deltaY, - } - : p, - ), - ); - }; - - const handlePointerUp = ( - upEvent: PointerEvent, - ) => { - el.removeEventListener( - "pointermove", - handlePointerMove, - ); - el.removeEventListener( - "pointerup", - handlePointerUp, - ); - el.releasePointerCapture( - upEvent.pointerId, - ); - interactionPauseRef.current?.resume(); - window - .getSelection() - ?.removeAllRanges(); - setTimeout(() => { - isDraggingRef.current = false; - }, 10); - }; - - el.addEventListener( - "pointermove", - handlePointerMove, - ); - el.addEventListener( - "pointerup", - handlePointerUp, - ); - } - } - > - Signature preview - - {/* Resize handles */} - {[ - { - position: "nw", - cursor: "nw-resize", - top: -4, - left: -4, - }, - { - position: "ne", - cursor: "ne-resize", - top: -4, - right: -4, - }, - { - position: "sw", - cursor: "sw-resize", - bottom: -4, - left: -4, - }, - { - position: "se", - cursor: "se-resize", - bottom: -4, - right: -4, - }, - ].map((handle) => ( -
{ - e.stopPropagation(); - e.preventDefault(); - const el = e.currentTarget; - el.setPointerCapture(e.pointerId); - interactionPauseRef.current?.pause(); - - const startX = e.clientX; - const startY = e.clientY; - const startWidth = preview.width; - const startHeight = - preview.height; - const startLeft = preview.x; - const startTop = preview.y; - - const handlePointerMove = ( - moveEvent: PointerEvent, - ) => { - isDraggingRef.current = true; - const deltaX = - (moveEvent.clientX - startX) / - width; - const deltaY = - (moveEvent.clientY - startY) / - height; - - let newWidth = startWidth; - let newHeight = startHeight; - let newX = startLeft; - let newY = startTop; - - // Min sizes as fractions: 50px / pageWidth, 25px / pageHeight - const minW = 50 / width; - const minH = 25 / height; - - if ( - handle.position.includes("e") - ) { - newWidth = Math.max( - minW, - startWidth + deltaX, - ); - } - if ( - handle.position.includes("w") - ) { - newWidth = Math.max( - minW, - startWidth - deltaX, - ); - newX = - startLeft + - (startWidth - newWidth); - } - if ( - handle.position.includes("s") - ) { - newHeight = Math.max( - minH, - startHeight + deltaY, - ); - } - if ( - handle.position.includes("n") - ) { - newHeight = Math.max( - minH, - startHeight - deltaY, - ); - newY = - startTop + - (startHeight - newHeight); - } - - setSignaturePreviews((prev) => - prev.map((p) => - p.id === preview.id - ? { - ...p, - x: newX, - y: newY, - width: newWidth, - height: newHeight, - } - : p, - ), - ); - }; - - const handlePointerUp = ( - upEvent: PointerEvent, - ) => { - el.removeEventListener( - "pointermove", - handlePointerMove, - ); - el.removeEventListener( - "pointerup", - handlePointerUp, - ); - el.releasePointerCapture( - upEvent.pointerId, - ); - interactionPauseRef.current?.resume(); - window - .getSelection() - ?.removeAllRanges(); - setTimeout(() => { - isDraggingRef.current = false; - }, 10); - }; - - el.addEventListener( - "pointermove", - handlePointerMove, - ); - el.addEventListener( - "pointerup", - handlePointerUp, - ); - }} - /> - ))} -
-
- - ); - })} - - {/* Hover preview: ghost signature following cursor in placement mode */} - {placementMode && - signatureData && - cursorOnPage?.pageIndex === pageIndex && ( - - )} -
- - - )} - /> - - - )} - - -
- ); - }, -); - -LocalEmbedPDFWithAnnotations.displayName = "LocalEmbedPDFWithAnnotations"; diff --git a/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.tsx b/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.tsx new file mode 100644 index 0000000000..0b6113d2d6 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.tsx @@ -0,0 +1,397 @@ +import { memo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import CloseIcon from "@mui/icons-material/Close"; +import { useInteractionManagerCapability } from "@embedpdf/plugin-interaction-manager/react"; +import { + Z_INDEX_SIGNATURE_OVERLAY, + Z_INDEX_SIGNATURE_OVERLAY_DELETE, + Z_INDEX_SIGNATURE_OVERLAY_HANDLE, +} from "@app/styles/zIndex"; +import type { SignaturePreview } from "@app/components/viewer/viewerTypes"; + +const DEFAULT_COLOR = "rgb(0, 122, 204)"; +const RESIZE_HANDLES = [ + { position: "nw", cursor: "nw-resize", top: -4, left: -4 }, + { position: "ne", cursor: "ne-resize", top: -4, right: -4 }, + { position: "sw", cursor: "sw-resize", bottom: -4, left: -4 }, + { position: "se", cursor: "se-resize", bottom: -4, right: -4 }, +] as const; + +export interface SignaturePreviewLayerProps { + pageIndex: number; + pageWidth: number; + pageHeight: number; + /** All previews across all pages; this layer renders only those matching pageIndex. */ + previews: SignaturePreview[]; + /** If true, previews cannot be moved, resized, or deleted. */ + readOnly: boolean; + /** When true (and not readOnly), clicking the page places a new preview. */ + placementMode: boolean; + /** Base64 PNG used for placement / ghost preview. */ + placementData?: string; + /** Signature type assigned to newly placed previews. */ + placementType?: "canvas" | "image" | "text"; + /** Emits the full updated preview array (across all pages) whenever it changes. */ + onChange: (previews: SignaturePreview[]) => void; + /** Currently selected preview id (managed by the parent layer). */ + selectedId?: string | null; + /** Notifies the parent when a preview is selected (used for deleteSelected/hasSelected). */ + onSelect?: (id: string | null) => void; +} + +/** Per-page overlay for signature previews; supports click-to-place, drag, resize, and delete. Coordinates are FRACTIONS (0–1) of the page. */ +export const SignaturePreviewLayer = memo(function SignaturePreviewLayer({ + pageIndex, + pageWidth, + pageHeight, + previews, + readOnly, + placementMode, + placementData, + placementType, + onChange, + selectedId, + onSelect, +}: SignaturePreviewLayerProps) { + const { t } = useTranslation(); + const { provides: interactionManager } = useInteractionManagerCapability(); + + // Track if a drag operation just occurred to prevent click from firing. + const isDraggingRef = useRef(false); + + // Track cursor position over this page for the ghost hover preview. + const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>( + null, + ); + + const pauseInteraction = () => interactionManager?.pause(); + const resumeInteraction = () => interactionManager?.resume(); + + const pagePreviews = previews.filter( + (preview) => preview.pageIndex === pageIndex, + ); + + const handlePlaceClick = (e: React.MouseEvent) => { + if (isDraggingRef.current) return; + if (readOnly || !placementMode || !placementData) return; + + const rect = e.currentTarget.getBoundingClientRect(); + // Store as fractions (0–1) of the rendered page so overlays remain correct + // at any zoom level. + const sigWidth = 150 / pageWidth; + const sigHeight = 75 / pageHeight; + const rawX = (e.clientX - rect.left) / pageWidth; + const rawY = (e.clientY - rect.top) / pageHeight; + const x = Math.max(0, Math.min(rawX - sigWidth / 2, 1 - sigWidth)); + const y = Math.max(0, Math.min(rawY - sigHeight / 2, 1 - sigHeight)); + + const newPreview: SignaturePreview = { + id: `sig-preview-${Date.now()}-${Math.random()}`, + pageIndex, + x, + y, + width: sigWidth, + height: sigHeight, + signatureData: placementData, + signatureType: placementType ?? "image", + }; + onChange([...previews, newPreview]); + onSelect?.(newPreview.id); + }; + + return ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + setCursorPos({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + : undefined + } + onMouseLeave={() => setCursorPos(null)} + onClick={!readOnly && placementMode ? handlePlaceClick : undefined} + > + {pagePreviews.map((preview) => { + if (!preview.signatureData) return null; + const color = preview.color ?? DEFAULT_COLOR; + const colorOpacity = (opacity: number) => + color.startsWith("rgb(") + ? color.replace("rgb(", "rgba(").replace(")", `, ${opacity})`) + : color; + return ( + +
+ {/* Delete button - only show when not read-only */} + {!readOnly && ( + { + e.stopPropagation(); + onChange(previews.filter((p) => p.id !== preview.id)); + if (selectedId === preview.id) onSelect?.(null); + }} + aria-label={t("viewer.signature.delete", "Delete signature")} + > + + + )} + +
{ + if ((e.target as HTMLElement).dataset.resizeHandle) + return; + e.stopPropagation(); + e.preventDefault(); + onSelect?.(preview.id); + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + pauseInteraction(); + + const startX = e.clientX; + const startY = e.clientY; + const startLeft = preview.x; + const startTop = preview.y; + + const handlePointerMove = (moveEvent: PointerEvent) => { + isDraggingRef.current = true; + const deltaX = + (moveEvent.clientX - startX) / pageWidth; + const deltaY = + (moveEvent.clientY - startY) / pageHeight; + onChange( + previews.map((p) => + p.id === preview.id + ? { + ...p, + x: startLeft + deltaX, + y: startTop + deltaY, + } + : p, + ), + ); + }; + + const handlePointerUp = (upEvent: PointerEvent) => { + el.removeEventListener( + "pointermove", + handlePointerMove, + ); + el.removeEventListener("pointerup", handlePointerUp); + el.releasePointerCapture(upEvent.pointerId); + resumeInteraction(); + window.getSelection()?.removeAllRanges(); + setTimeout(() => { + isDraggingRef.current = false; + }, 10); + }; + + el.addEventListener("pointermove", handlePointerMove); + el.addEventListener("pointerup", handlePointerUp); + } + } + > + Signature preview + + {/* Resize handles */} + {!readOnly && + RESIZE_HANDLES.map((handle) => ( +
{ + e.stopPropagation(); + e.preventDefault(); + onSelect?.(preview.id); + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + pauseInteraction(); + + const startX = e.clientX; + const startY = e.clientY; + const startWidth = preview.width; + const startHeight = preview.height; + const startLeft = preview.x; + const startTop = preview.y; + + const handlePointerMove = (moveEvent: PointerEvent) => { + isDraggingRef.current = true; + const deltaX = + (moveEvent.clientX - startX) / pageWidth; + const deltaY = + (moveEvent.clientY - startY) / pageHeight; + + let newWidth = startWidth; + let newHeight = startHeight; + let newX = startLeft; + let newY = startTop; + + // Min sizes as fractions: 50px / pageWidth, 25px / pageHeight + const minW = 50 / pageWidth; + const minH = 25 / pageHeight; + + if (handle.position.includes("e")) { + newWidth = Math.max(minW, startWidth + deltaX); + } + if (handle.position.includes("w")) { + newWidth = Math.max(minW, startWidth - deltaX); + newX = startLeft + (startWidth - newWidth); + } + if (handle.position.includes("s")) { + newHeight = Math.max(minH, startHeight + deltaY); + } + if (handle.position.includes("n")) { + newHeight = Math.max(minH, startHeight - deltaY); + newY = startTop + (startHeight - newHeight); + } + + onChange( + previews.map((p) => + p.id === preview.id + ? { + ...p, + x: newX, + y: newY, + width: newWidth, + height: newHeight, + } + : p, + ), + ); + }; + + const handlePointerUp = (upEvent: PointerEvent) => { + el.removeEventListener( + "pointermove", + handlePointerMove, + ); + el.removeEventListener("pointerup", handlePointerUp); + el.releasePointerCapture(upEvent.pointerId); + resumeInteraction(); + window.getSelection()?.removeAllRanges(); + setTimeout(() => { + isDraggingRef.current = false; + }, 10); + }; + + el.addEventListener("pointermove", handlePointerMove); + el.addEventListener("pointerup", handlePointerUp); + }} + /> + ))} +
+
+ + ); + })} + + {/* Hover preview: ghost signature following cursor in placement mode */} + {!readOnly && placementMode && placementData && cursorPos && ( + + )} +
+ ); +}); diff --git a/frontend/editor/src/core/components/viewer/Viewer.tsx b/frontend/editor/src/core/components/viewer/Viewer.tsx index 7052df4c67..08103ca1f5 100644 --- a/frontend/editor/src/core/components/viewer/Viewer.tsx +++ b/frontend/editor/src/core/components/viewer/Viewer.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import EmbedPdfViewer from "@app/components/viewer/EmbedPdfViewer"; +import type { EmbedPdfViewerProps } from "@app/components/viewer/EmbedPdfViewer"; import { NonPdfViewerWrapper, type ViewerProps, @@ -11,7 +12,20 @@ import { isPdfFile } from "@app/utils/fileUtils"; export type { ViewerProps }; -const Viewer = (props: ViewerProps) => { +// Signature-overlay props live on EmbedPdfViewerProps; Viewer passes them through +// so callers can drive the overlay. They don't apply to the non-PDF viewer. +type SignatureOverlayPassThrough = Pick< + EmbedPdfViewerProps, + | "signaturePreviews" + | "signaturePreviewsReadOnly" + | "signaturePlacementMode" + | "signaturePlacementData" + | "signaturePlacementType" + | "onSignaturePreviewsChange" + | "signatureOverlayApiRef" +>; + +const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => { const { selectors } = useFileState(); const activeFiles = selectors.getFiles(); const { activeFileId } = useViewer(); diff --git a/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx b/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx new file mode 100644 index 0000000000..36a2701d14 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { ActionIcon, Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import ShareIcon from "@mui/icons-material/Share"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { uploadHistoryChain } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { alert } from "@app/components/toast"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +interface ViewerShareButtonProps { + disabled?: boolean; +} + +const BUTTON_WIDTH = "13rem"; + +/** + * Share button for the viewer workbench bar's global action group (alongside + * Print/Download/Close). Sharing operates on server-stored files, so if the + * active file is local-only it first prompts the user to save it to the server, + * then continues straight into the sharing modal. + */ +export default function ViewerShareButton({ + disabled, +}: ViewerShareButtonProps) { + const { t } = useTranslation(); + const { activeFileId } = useViewer(); + const { selectors } = useFileState(); + const { actions } = useFileActions(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [shareStub, setShareStub] = useState(null); + + // Resolve strictly to the file shown in the viewer. Never fall back to an + // arbitrary file — sharing the wrong document would be worse than not + // sharing. If there's no active file, the button is disabled (see isDisabled). + const stubs = selectors.getStirlingFileStubs(); + const stub = activeFileId + ? stubs.find((s) => s.id === activeFileId) + : undefined; + + const label = t("workbenchBar.share", "Share"); + const isDisabled = Boolean(disabled) || !stub; + + const openShare = (target: StirlingFileStub) => { + setShareStub(target); + setShareOpen(true); + }; + + const handleClick = () => { + if (!stub) return; + if (stub.remoteStorageId) { + openShare(stub); + } else { + setConfirmOpen(true); + } + }; + + const handleSaveAndShare = async () => { + if (!stub) return; + setSaving(true); + try { + const originalFileId = (stub.originalFileId || stub.id) as FileId; + const { remoteId, updatedAt, chain } = await uploadHistoryChain( + originalFileId, + stub.remoteStorageId, + ); + const metadata = { + remoteStorageId: remoteId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteSharedViaLink: false, + }; + // Best-effort local cache sync — server upload is the source of truth. + // Swallow local write failures; the file is already on the server. + try { + await Promise.all( + chain.map((s) => { + actions.updateStirlingFileStub(s.id, metadata); + return fileStorage.updateFileMetadata(s.id, metadata); + }), + ); + } catch (cacheError) { + console.error( + "Saved to server, but failed to sync local file metadata:", + cacheError, + ); + } + setConfirmOpen(false); + openShare({ ...stub, ...metadata }); + } catch (error) { + console.error("Failed to save file to server for sharing:", error); + const status = (error as { response?: { status?: number } })?.response + ?.status; + alert({ + alertType: "error", + title: t("storageShare.saveFailedTitle", "Couldn't save to server"), + body: + status === 403 + ? t( + "storageUpload.featureDisabled", + "Saving to the server isn't enabled on this server.", + ) + : t( + "storageShare.saveFailed", + "Failed to save the file to the server. Please try again.", + ), + expandable: false, + durationMs: 3000, + }); + } finally { + setSaving(false); + } + }; + + return ( + <> + +
+ + + +
+
+ + { + if (!saving) setConfirmOpen(false); + }} + centered + size="auto" + radius="lg" + title={t("storageShare.saveFirstTitle", "Share file")} + zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} + overlayProps={{ blur: 4 }} + > + + + + {t( + "storageShare.saveFirstHeading", + "Save this file to the server to share it", + )} + + + {t( + "storageShare.saveFirstBody", + "Sharing works on files saved to the server. We'll save it to your files, then continue to sharing.", + )} + + + + + + + + + + {shareStub && ( + setShareOpen(false)} + file={shareStub} + /> + )} + + ); +} diff --git a/frontend/editor/src/core/components/viewer/viewerTypes.ts b/frontend/editor/src/core/components/viewer/viewerTypes.ts index 9233e74ca8..37ebe86341 100644 --- a/frontend/editor/src/core/components/viewer/viewerTypes.ts +++ b/frontend/editor/src/core/components/viewer/viewerTypes.ts @@ -3,6 +3,28 @@ export interface AnnotationRect { size: { width: number; height: number }; } +/** Signature preview overlay placed on a PDF page; position/size are FRACTIONS (0–1) of the rendered page. */ +export interface SignaturePreview { + id: string; + pageIndex: number; + x: number; + y: number; + width: number; + height: number; + signatureData: string; // Base64 PNG image + signatureType: "canvas" | "image" | "text"; + color?: string; // Per-participant color (rgb(...) string); falls back to default blue + participantName?: string; // Shown in tooltip on hover +} + +/** Imperative API for managing signature preview overlays in the shared viewer. */ +export interface SignatureOverlayAPI { + getSignaturePreviews: () => SignaturePreview[]; + clearPreviews: () => void; + deleteSelected: () => void; + hasSelected: () => boolean; +} + export interface ClearDocumentAnnotationsResult { available: boolean; cleared: boolean; diff --git a/frontend/editor/src/core/contexts/SigningOverlayContext.tsx b/frontend/editor/src/core/contexts/SigningOverlayContext.tsx new file mode 100644 index 0000000000..f24ceeb6fc --- /dev/null +++ b/frontend/editor/src/core/contexts/SigningOverlayContext.tsx @@ -0,0 +1,55 @@ +import React, { createContext, useContext, useMemo, useState } from "react"; +import type { + SignaturePreview, + SignatureOverlayAPI, +} from "@app/components/viewer/viewerTypes"; + +/** Signing document + signature-overlay props the Shared Signing sidebar tool feeds to the main Workbench Viewer. */ +export interface SigningOverlay { + file: File | null; + signaturePreviews?: SignaturePreview[]; + signaturePreviewsReadOnly?: boolean; + signaturePlacementMode?: boolean; + signaturePlacementData?: string; + signaturePlacementType?: "canvas" | "image" | "text"; + onSignaturePreviewsChange?: (previews: SignaturePreview[]) => void; + signatureOverlayApiRef?: React.RefObject; +} + +interface SigningOverlayContextValue { + overlay: SigningOverlay | null; + setOverlay: React.Dispatch>; +} + +const SigningOverlayContext = createContext< + SigningOverlayContextValue | undefined +>(undefined); + +export function SigningOverlayProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [overlay, setOverlay] = useState(null); + + const value = useMemo( + () => ({ overlay, setOverlay }), + [overlay], + ); + + return ( + + {children} + + ); +} + +export function useSigningOverlay(): SigningOverlayContextValue { + const ctx = useContext(SigningOverlayContext); + if (!ctx) { + throw new Error( + "useSigningOverlay must be used within a SigningOverlayProvider", + ); + } + return ctx; +} diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 605c6a3925..58b61c71be 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -1,5 +1,6 @@ import { lazy, useMemo } from "react"; import LocalIcon from "@app/components/shared/LocalIcon"; +import GroupAddOutlinedIcon from "@mui/icons-material/GroupAddOutlined"; import { useTranslation } from "react-i18next"; import { devApiLink } from "@app/constants/links"; import { reorganizePagesOperationConfig } from "@app/hooks/tools/reorganizePages/useReorganizePagesOperation"; @@ -221,6 +222,20 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { synonyms: getSynonyms(t, "sign"), supportsAutomate: false, //TODO make support Sign }, + sharedSign: { + icon: , + name: t("home.sharedSign.title", "Shared Signing"), + component: lazy(() => import("@app/tools/SharedSign")), + description: t( + "home.sharedSign.desc", + "Request signatures from others and track signing sessions", + ), + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.SIGNING, + automationSettings: null, + supportsAutomate: false, + synonyms: getSynonyms(t, "sharedSign"), + }, addText: { icon: ( + request.myStatus !== "SIGNED" && request.myStatus !== "DECLINED", + ).length; + + const ownerUpdates = mySessions.filter( + (session) => + !session.finalized && + session.signedCount > getLastSeenSignedCount(session.sessionId), + ).length; + + return incoming + ownerUpdates; +} diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts b/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts new file mode 100644 index 0000000000..2a2b001913 --- /dev/null +++ b/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts @@ -0,0 +1,472 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import apiClient from "@app/services/apiClient"; +import { alert } from "@app/components/toast"; +import { fileStorage } from "@app/services/fileStorage"; +import { createFileFromApiResponse } from "@app/utils/fileResponseUtils"; +import { + SignRequestSummary, + SignRequestDetail, + SessionSummary, + SessionDetail, +} from "@app/types/signingSession"; +import type { SignaturePreview } from "@app/components/viewer/viewerTypes"; +import { getFileColor } from "@app/components/pageEditor/fileColors"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; +import { useFileActions } from "@app/contexts/FileContext"; +import { useSigningOverlay } from "@app/contexts/SigningOverlayContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; +import { useSigningSessions } from "@app/hooks/signing/useSigningSessions"; +import { markSessionSeen } from "@app/services/signingSeenStore"; +import type { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput"; + +/** Which Shared Signing screen the sidebar tool is currently showing. */ +export type SigningView = "list" | "detail" | "request"; + +/** Data the session-detail sidebar panel needs to render and act. */ +export interface SigningDetailData { + session: SessionDetail; + pdfFile: File | null; + onFinalize: () => Promise; + onLoadSignedPdf: () => Promise; + onAddParticipants: ( + userIds: number[], + defaultReason?: string, + ) => Promise; + onRemoveParticipant: (participantId: number) => Promise; + onDelete: () => Promise; + onBack: () => void; + onRefresh: () => Promise; +} + +/** Data the sign-request sidebar panel needs to render and act. */ +export interface SigningRequestData { + signRequest: SignRequestDetail; + pdfFile: File; + onSign: (certificateData: FormData) => Promise; + onDecline: () => Promise; + onBack: () => void; + canSign: boolean; +} + +function countSignedParticipants(session: SessionDetail): number { + return session.participants.filter((p) => p.status === "SIGNED").length; +} + +// Read-only overlay previews for every participant's already-placed wet +// signatures, coloured per participant (matches the participant list dots). +function computeWetSignaturePreviews( + session: SessionDetail, +): SignaturePreview[] { + const previews: SignaturePreview[] = []; + session.participants.forEach((participant, participantIndex) => { + if (participant.wetSignatures && participant.wetSignatures.length > 0) { + const color = getFileColor(participantIndex); + const participantName = participant.name || participant.email; + participant.wetSignatures.forEach((wetSig, sigIndex) => { + previews.push({ + id: `participant-${participant.userId}-sig-${sigIndex}`, + pageIndex: wetSig.page, + x: wetSig.x, + y: wetSig.y, + width: wetSig.width, + height: wetSig.height, + signatureData: wetSig.data, + signatureType: "image" as const, + color, + participantName, + }); + }); + } + }); + return previews; +} + +/** Owns Shared Signing state: data fetch, session creation, and opening a request/session into the sidebar tool (driving the viewer overlay). */ +export function useSigningSessionController(enabled: boolean) { + const { t } = useTranslation(); + const { signRequests, mySessions, loading, refetch } = useSigningSessions({ + enabled, + autoRefreshInterval: enabled ? 15000 : 0, + }); + const { actions: fileActions } = useFileActions(); + // In viewer mode this is the single displayed file; matches how tools scope + // their input (see useBaseTool / useViewScopedFiles). Creating a session + // requires exactly one file. + const selectedFiles = useViewScopedFiles(); + const { actions: navigationActions } = useNavigationActions(); + const { setOverlay } = useSigningOverlay(); + + const [creating, setCreating] = useState(false); + const [view, setView] = useState("list"); + const [detailData, setDetailData] = useState(null); + const [requestData, setRequestData] = useState( + null, + ); + + // Leaving the tool (panel unmounts) must not leave the signing document and + // overlays lingering on the shared viewer. + useEffect(() => { + return () => setOverlay(null); + }, [setOverlay]); + + const backToList = useCallback(() => { + setOverlay(null); + setDetailData(null); + setRequestData(null); + setView("list"); + }, [setOverlay]); + + // --- Action handlers (invoked from the sidebar panels) --- + + const handleSign = async (sessionId: string, certificateData: FormData) => { + await apiClient.post( + `/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, + certificateData, + ); + alert({ + alertType: "success", + title: t("success"), + body: t("signRequest.signed", "Document signed successfully"), + expandable: false, + durationMs: 2500, + }); + backToList(); + await refetch(); + }; + + const handleDecline = async (sessionId: string) => { + await apiClient.post( + `/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`, + ); + alert({ + alertType: "success", + title: t("success"), + body: t("signRequest.declined", "Sign request declined"), + expandable: false, + durationMs: 2500, + }); + backToList(); + await refetch(); + }; + + const handleFinalize = async (sessionId: string, documentName: string) => { + const response = await apiClient.post( + `/api/v1/security/cert-sign/sessions/${sessionId}/finalize`, + null, + { responseType: "blob" }, + ); + const signedFile = createFileFromApiResponse( + response.data, + response.headers, + `${documentName}_signed.pdf`, + ); + await fileActions.addFiles([signedFile], { skipUploadTracking: true }); + alert({ + alertType: "success", + title: t("success"), + body: t("certSign.sessions.finalized", "Session finalized"), + expandable: false, + durationMs: 2500, + }); + backToList(); + await refetch(); + }; + + const handleLoadSignedPdf = async ( + sessionId: string, + documentName: string, + ) => { + const response = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`, + { responseType: "blob" }, + ); + const signedFile = createFileFromApiResponse( + response.data, + response.headers, + `${documentName}_signed.pdf`, + ); + await fileActions.addFiles([signedFile], { skipUploadTracking: true }); + alert({ + alertType: "success", + title: t("success"), + body: t("certSign.sessions.loaded", "Signed PDF loaded"), + expandable: false, + durationMs: 2500, + }); + backToList(); + }; + + const handleRefreshSession = async (sessionId: string) => { + const response = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${sessionId}`, + ); + const session = response.data; + markSessionSeen(session.sessionId, countSignedParticipants(session)); + setDetailData((prev) => (prev ? { ...prev, session } : prev)); + // Keep the read-only overlay in sync as participants sign. + setOverlay((prev) => + prev + ? { ...prev, signaturePreviews: computeWetSignaturePreviews(session) } + : prev, + ); + }; + + const handleAddParticipants = async ( + sessionId: string, + userIds: number[], + defaultReason?: string, + ) => { + const requests = userIds.map((userId) => ({ + userId, + defaultReason: defaultReason || undefined, + sendNotification: true, + })); + await apiClient.post( + `/api/v1/security/cert-sign/sessions/${sessionId}/participants`, + requests, + ); + await handleRefreshSession(sessionId); + }; + + const handleRemoveParticipant = async ( + sessionId: string, + participantId: number, + ) => { + await apiClient.delete( + `/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participantId}`, + ); + await handleRefreshSession(sessionId); + }; + + const handleDeleteSession = async (sessionId: string) => { + await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}`); + alert({ + alertType: "success", + title: t("success"), + body: t("certSign.sessions.deleted", "Session deleted"), + expandable: false, + durationMs: 2500, + }); + backToList(); + await refetch(); + }; + + // --- Open into the sidebar detail/request views --- + + const openSignRequest = async (request: SignRequestSummary) => { + try { + const [detailResponse, pdfResponse] = await Promise.all([ + apiClient.get( + `/api/v1/security/cert-sign/sign-requests/${request.sessionId}`, + ), + apiClient.get( + `/api/v1/security/cert-sign/sign-requests/${request.sessionId}/document`, + { responseType: "blob" }, + ), + ]); + const pdfFile = new File( + [pdfResponse.data], + detailResponse.data.documentName, + { type: "application/pdf" }, + ); + const canSign = + detailResponse.data.myStatus === "PENDING" || + detailResponse.data.myStatus === "NOTIFIED" || + detailResponse.data.myStatus === "VIEWED"; + + // Seed the viewer with the document immediately; the request panel enriches + // the overlay with interactive placement props once it mounts. + setOverlay({ file: pdfFile }); + setRequestData({ + signRequest: detailResponse.data, + pdfFile, + onSign: (certData: FormData) => handleSign(request.sessionId, certData), + onDecline: () => handleDecline(request.sessionId), + onBack: backToList, + canSign, + }); + setView("request"); + navigationActions.setWorkbench("viewer"); + } catch (error) { + console.error( + "Failed to load sign request:", + error instanceof Error ? error.message : error, + ); + alert({ + alertType: "error", + title: t("common.error"), + body: t("signRequest.fetchFailed", "Failed to load sign request"), + expandable: false, + durationMs: 3000, + }); + } + }; + + const openSession = async (session: SessionSummary) => { + try { + const detailResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}`, + ); + // Owner is now viewing this session — clear its "new signatures" badge. + markSessionSeen( + session.sessionId, + countSignedParticipants(detailResponse.data), + ); + let pdfFile: File | null = null; + if (detailResponse.data.finalized) { + try { + const pdfResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}/signed-pdf`, + { responseType: "blob" }, + ); + pdfFile = new File([pdfResponse.data], session.documentName, { + type: "application/pdf", + }); + } catch (pdfError: any) { + if (pdfError?.response?.status === 404) { + alert({ + alertType: "warning", + title: t("certSign.sessions.pdfNotReady", "PDF Not Ready"), + body: t( + "certSign.sessions.pdfNotReadyDesc", + "The signed PDF is being generated. Please try again in a moment.", + ), + expandable: false, + durationMs: 3000, + }); + return; + } + throw pdfError; + } + } else { + try { + const pdfResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}/pdf`, + { responseType: "blob" }, + ); + pdfFile = new File([pdfResponse.data], session.documentName, { + type: "application/pdf", + }); + } catch (_error) { + pdfFile = null; + } + } + + setOverlay({ + file: pdfFile, + signaturePreviews: computeWetSignaturePreviews(detailResponse.data), + signaturePreviewsReadOnly: true, + }); + setDetailData({ + session: detailResponse.data, + pdfFile, + onFinalize: () => + handleFinalize(session.sessionId, session.documentName), + onLoadSignedPdf: () => + handleLoadSignedPdf(session.sessionId, session.documentName), + onAddParticipants: (userIds: number[], defaultReason?: string) => + handleAddParticipants(session.sessionId, userIds, defaultReason), + onRemoveParticipant: (participantId: number) => + handleRemoveParticipant(session.sessionId, participantId), + onDelete: () => handleDeleteSession(session.sessionId), + onBack: backToList, + onRefresh: () => handleRefreshSession(session.sessionId), + }); + setView("detail"); + navigationActions.setWorkbench("viewer"); + } catch (error) { + console.error( + "Failed to load session:", + error instanceof Error ? error.message : error, + ); + alert({ + alertType: "error", + title: t("common.error"), + body: t( + "certSign.sessions.fetchFailed", + "Failed to load session details", + ), + expandable: false, + durationMs: 3000, + }); + } + }; + + // --- Create a new signing request from the currently selected file --- + + const createSession = async ( + signatureSettings: SignatureSettings, + selectedUserIds: number[], + dueDate: string, + ): Promise => { + if (selectedUserIds.length === 0 || selectedFiles.length !== 1) { + return false; + } + setCreating(true); + try { + const selectedFile = selectedFiles[0]; + const stirlingFile = await fileStorage.getStirlingFile( + selectedFile.fileId, + ); + if (!stirlingFile) throw new Error("File not found"); + + const formData = new FormData(); + formData.append("file", stirlingFile, selectedFile.name); + formData.append("workflowType", "SIGNING"); + formData.append("documentName", selectedFile.name); + selectedUserIds.forEach((userId, index) => { + formData.append(`participantUserIds[${index}]`, userId.toString()); + }); + if (dueDate) formData.append("dueDate", dueDate); + formData.append("notifyOnCreate", "true"); + if (signatureSettings.includeSummaryPage) { + formData.append( + "workflowMetadata", + JSON.stringify({ includeSummaryPage: true }), + ); + } + + await apiClient.post("/api/v1/security/cert-sign/sessions", formData); + alert({ + alertType: "success", + title: t("success"), + body: t("signSession.created", "Signing request sent"), + expandable: false, + durationMs: 2500, + }); + await refetch(); + return true; + } catch (error) { + console.error( + "Failed to create session:", + error instanceof Error ? error.message : error, + ); + alert({ + alertType: "error", + title: t("common.error"), + body: t("signSession.createFailed", "Failed to create signing request"), + expandable: false, + durationMs: 3000, + }); + return false; + } finally { + setCreating(false); + } + }; + + return { + signRequests, + mySessions, + loading, + creating, + refetch, + createSession, + openSignRequest, + openSession, + view, + detailData, + requestData, + backToList, + }; +} diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts index 519e43c24a..785e792414 100644 --- a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts +++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts @@ -32,39 +32,52 @@ export const useSigningSessions = ( const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const fetchData = useCallback(async () => { - if (!enabled) return; + const fetchData = useCallback( + async (opts?: { silent?: boolean }) => { + if (!enabled) return; - setLoading(true); - setError(null); + // Background auto-refreshes pass { silent: true } to skip the loading spinner + // and failure toasts; only the initial load and explicit refetch surface errors. + const silent = opts?.silent ?? false; - try { - const [requestsResponse, sessionsResponse] = await Promise.all([ - apiClient.get( - "/api/v1/security/cert-sign/sign-requests", - ), - apiClient.get("/api/v1/security/cert-sign/sessions"), - ]); + if (!silent) setLoading(true); + setError(null); - setSignRequests(requestsResponse.data); - setMySessions(sessionsResponse.data); - } catch (err) { - const errorObj = - err instanceof Error ? err : new Error("Failed to fetch signing data"); - setError(errorObj); - console.error("Failed to fetch signing data:", err); + try { + const [requestsResponse, sessionsResponse] = await Promise.all([ + apiClient.get( + "/api/v1/security/cert-sign/sign-requests", + ), + apiClient.get( + "/api/v1/security/cert-sign/sessions", + ), + ]); - alert({ - alertType: "warning", - title: t("common.error"), - body: t("certSign.fetchFailed", "Failed to load signing data"), - expandable: false, - durationMs: 2500, - }); - } finally { - setLoading(false); - } - }, [enabled, t]); + setSignRequests(requestsResponse.data); + setMySessions(sessionsResponse.data); + } catch (err) { + const errorObj = + err instanceof Error + ? err + : new Error("Failed to fetch signing data"); + setError(errorObj); + console.error("Failed to fetch signing data:", err); + + if (!silent) { + alert({ + alertType: "warning", + title: t("common.error"), + body: t("certSign.fetchFailed", "Failed to load signing data"), + expandable: false, + durationMs: 2500, + }); + } + } finally { + if (!silent) setLoading(false); + } + }, + [enabled, t], + ); // Initial fetch useEffect(() => { @@ -80,7 +93,7 @@ export const useSigningSessions = ( } const interval = setInterval(() => { - fetchData(); + fetchData({ silent: true }); }, autoRefreshInterval); return () => clearInterval(interval); diff --git a/frontend/editor/src/core/hooks/signing/useSigningWorkbench.ts b/frontend/editor/src/core/hooks/signing/useSigningWorkbench.ts deleted file mode 100644 index 64991283ca..0000000000 --- a/frontend/editor/src/core/hooks/signing/useSigningWorkbench.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import SignRequestWorkbenchView from "@app/components/tools/certSign/SignRequestWorkbenchView"; -import SessionDetailWorkbenchView from "@app/components/tools/certSign/SessionDetailWorkbenchView"; - -export interface WorkbenchRegistration { - id: string; - workbenchId: string; - label: string; - component: React.ComponentType; -} - -export interface UseSigningWorkbenchResult { - signRequestWorkbench: { - id: string; - type: string; - }; - sessionDetailWorkbench: { - id: string; - type: string; - }; -} - -/** - * Hook to manage custom workbench registration for signing workflows. - * Automatically registers and unregisters workbenches on mount/unmount. - */ -export const useSigningWorkbench = (): UseSigningWorkbenchResult => { - const { t } = useTranslation(); - const { registerCustomWorkbenchView, unregisterCustomWorkbenchView } = - useToolWorkflow(); - - // Define workbench IDs as constants - const SIGN_REQUEST_WORKBENCH_ID = "signRequestWorkbench"; - const SIGN_REQUEST_WORKBENCH_TYPE = "custom:signRequestWorkbench" as const; - const SESSION_DETAIL_WORKBENCH_ID = "sessionDetailWorkbench"; - const SESSION_DETAIL_WORKBENCH_TYPE = - "custom:sessionDetailWorkbench" as const; - - // Register workbenches on mount - useEffect(() => { - registerCustomWorkbenchView({ - id: SIGN_REQUEST_WORKBENCH_ID, - workbenchId: SIGN_REQUEST_WORKBENCH_TYPE, - label: t("certSign.collab.signRequest.workbenchTitle", "Sign Request"), - component: SignRequestWorkbenchView, - }); - - registerCustomWorkbenchView({ - id: SESSION_DETAIL_WORKBENCH_ID, - workbenchId: SESSION_DETAIL_WORKBENCH_TYPE, - label: t( - "certSign.collab.sessionDetail.workbenchTitle", - "Session Management", - ), - component: SessionDetailWorkbenchView, - }); - - return () => { - unregisterCustomWorkbenchView(SIGN_REQUEST_WORKBENCH_ID); - unregisterCustomWorkbenchView(SESSION_DETAIL_WORKBENCH_ID); - }; - }, [registerCustomWorkbenchView, unregisterCustomWorkbenchView, t]); - - return useMemo( - () => ({ - signRequestWorkbench: { - id: SIGN_REQUEST_WORKBENCH_ID, - type: SIGN_REQUEST_WORKBENCH_TYPE, - }, - sessionDetailWorkbench: { - id: SESSION_DETAIL_WORKBENCH_ID, - type: SESSION_DETAIL_WORKBENCH_TYPE, - }, - }), - [], - ); -}; diff --git a/frontend/editor/src/core/services/signingSeenStore.ts b/frontend/editor/src/core/services/signingSeenStore.ts new file mode 100644 index 0000000000..04df413323 --- /dev/null +++ b/frontend/editor/src/core/services/signingSeenStore.ts @@ -0,0 +1,52 @@ +/** + * Tracks how many signatures the user had already seen on each signing session + * they own. Used to surface a badge when participants have signed a session + * since the owner last opened it. Persisted in localStorage so "seen" survives + * reloads; a module-level version counter lets hooks re-read reactively. + */ + +const STORAGE_KEY = "stirling.signing.lastSeenSigned"; + +type SeenMap = Record; + +let version = 0; +const listeners = new Set<() => void>(); + +function read(): SeenMap { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as SeenMap) : {}; + } catch { + return {}; + } +} + +/** Signed count the owner last saw for a session (0 if never opened). */ +export function getLastSeenSignedCount(sessionId: string): number { + return read()[sessionId] ?? 0; +} + +/** Record the signed count the owner just saw for a session. */ +export function markSessionSeen(sessionId: string, signedCount: number): void { + try { + const map = read(); + if (map[sessionId] === signedCount) return; + map[sessionId] = signedCount; + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + // Storage may be unavailable (private mode); badge degrades gracefully. + } + version += 1; + listeners.forEach((listener) => listener()); +} + +/** Subscribe to "seen" changes (for useSyncExternalStore). */ +export function subscribeSigningSeen(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Monotonic version, bumped whenever a session is marked seen. */ +export function getSigningSeenVersion(): number { + return version; +} diff --git a/frontend/editor/src/core/tools/SharedSign.tsx b/frontend/editor/src/core/tools/SharedSign.tsx new file mode 100644 index 0000000000..82b5787c41 --- /dev/null +++ b/frontend/editor/src/core/tools/SharedSign.tsx @@ -0,0 +1,361 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Badge, + Button, + Center, + Chip, + Group, + Loader, + Paper, + SegmentedControl, + Stack, + Text, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import AddIcon from "@mui/icons-material/Add"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import type { BaseToolProps } from "@app/types/tool"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; +import { useSigningSessionController } from "@app/hooks/signing/useSigningSessionController"; +import { CreateSessionFlow } from "@app/components/shared/signing/CreateSessionFlow"; +import { SessionDetailPanel } from "@app/components/tools/certSign/panels/SessionDetailPanel"; +import SignRequestPanel from "@app/components/tools/certSign/panels/SignRequestPanel"; +import type { + SignRequestSummary, + SessionSummary, +} from "@app/types/signingSession"; + +type Tab = "active" | "completed"; + +type SessionItem = + | (SignRequestSummary & { itemType: "signRequest" }) + | (SessionSummary & { itemType: "mySession" }); + +function sortByRecency(items: SessionItem[]): SessionItem[] { + return [...items].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); +} + +const SharedSign = (_props: BaseToolProps) => { + const { t } = useTranslation(); + const groupSigningEnabled = useGroupSigningEnabled(); + const controller = useSigningSessionController(groupSigningEnabled); + const selectedFiles = useViewScopedFiles(); + + const [tab, setTab] = useState("active"); + const [filters, setFilters] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [dueDate, setDueDate] = useState(""); + + // Switching tabs clears filters (the available chips differ per tab). + const changeTab = (value: Tab) => { + setTab(value); + setFilters([]); + }; + + const { signRequests, mySessions } = controller; + + const activeItems = useMemo( + () => + sortByRecency([ + ...signRequests + .filter((r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED") + .map((r) => ({ ...r, itemType: "signRequest" as const })), + ...mySessions + .filter((s) => !s.finalized) + .map((s) => ({ ...s, itemType: "mySession" as const })), + ]), + [signRequests, mySessions], + ); + + const completedItems = useMemo( + () => + sortByRecency([ + ...signRequests + .filter((r) => r.myStatus === "SIGNED" || r.myStatus === "DECLINED") + .map((r) => ({ ...r, itemType: "signRequest" as const })), + ...mySessions + .filter((s) => s.finalized) + .map((s) => ({ ...s, itemType: "mySession" as const })), + ]), + [signRequests, mySessions], + ); + + const statusFor = (item: SessionItem): { color: string; label: string } => { + if (item.itemType === "mySession") { + const s = item as SessionSummary; + if (s.finalized) { + return { color: "green", label: t("certSign.finalized", "Finalized") }; + } + const signed = s.signedCount ?? 0; + const total = s.participantCount ?? 0; + if (total > 0 && signed === total) { + return { + color: "green", + label: t("certSign.readyToFinalize", "Ready to finalize"), + }; + } + if (total > 0) { + return { + color: signed > 0 ? "yellow" : "blue", + label: t( + "certSign.signatureProgress", + "{{signedCount}}/{{totalCount}} signatures", + { signedCount: signed, totalCount: total }, + ), + }; + } + return { + color: "blue", + label: t("certSign.awaitingSignatures", "Awaiting signatures"), + }; + } + const req = item as SignRequestSummary; + switch (req.myStatus) { + case "SIGNED": + return { color: "green", label: t("certSign.signed", "Signed") }; + case "DECLINED": + return { color: "red", label: t("certSign.declined", "Declined") }; + case "VIEWED": + return { color: "blue", label: t("certSign.viewed", "Viewed") }; + default: + return { color: "orange", label: t("certSign.pending", "Pending") }; + } + }; + + const onItemClick = (item: SessionItem) => { + if (item.itemType === "signRequest") { + void controller.openSignRequest(item as SignRequestSummary); + } else { + void controller.openSession(item as SessionSummary); + } + }; + + if (!groupSigningEnabled) { + return ( + + + {t( + "sharedSign.disabledBody", + "Collaborative signing isn't enabled on this server.", + )} + + + ); + } + + if (controller.view === "detail" && controller.detailData) { + return ; + } + + if (controller.view === "request" && controller.requestData) { + return ; + } + + if (showCreate) { + return ( + + + + + { + void controller + .createSession(settings, selectedUserIds, dueDate) + .then((ok) => { + if (ok) { + setShowCreate(false); + setSelectedUserIds([]); + setDueDate(""); + changeTab("active"); + } + }); + }} + /> + + ); + } + + const filterOptions = + tab === "active" + ? [ + { key: "mine", label: t("sharedSign.filterMine", "Mine") }, + { key: "overdue", label: t("sharedSign.filterOverdue", "Overdue") }, + ] + : [ + { key: "mine", label: t("sharedSign.filterMine", "Mine") }, + { key: "signed", label: t("sharedSign.filterSigned", "Signed") }, + { + key: "declined", + label: t("sharedSign.filterDeclined", "Declined"), + }, + ]; + + const applyFilters = (list: SessionItem[]): SessionItem[] => { + let result = list; + const now = Date.now(); + if (filters.includes("mine")) { + result = result.filter((s) => s.itemType === "mySession"); + } + if (filters.includes("overdue")) { + // Only sign requests carry a dueDate; owned sessions (SessionSummary) don't + // expose one in the list payload, so overdue filtering requires a backend change. + result = result.filter( + (s) => + s.itemType === "signRequest" && + Boolean(s.dueDate) && + new Date(s.dueDate).getTime() < now, + ); + } + if (filters.includes("signed")) { + result = result.filter( + (s) => (s as SignRequestSummary).myStatus === "SIGNED", + ); + } + if (filters.includes("declined")) { + result = result.filter( + (s) => (s as SignRequestSummary).myStatus === "DECLINED", + ); + } + return result; + }; + + const items = applyFilters(tab === "active" ? activeItems : completedItems); + + return ( + + changeTab(value as Tab)} + data={[ + { label: t("sharedSign.tab.active", "Active"), value: "active" }, + { + label: t("sharedSign.tab.completed", "Completed"), + value: "completed", + }, + ]} + /> + + + + + + {filterOptions.map((f) => ( + + {f.label} + + ))} + + + + {controller.loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( + + {tab === "active" + ? t( + "sharedSign.empty.active", + "No pending sign requests or active sessions", + ) + : t("sharedSign.empty.completed", "No completed sessions")} + + ) : ( + + {items.map((item) => { + const status = statusFor(item); + const parts: string[] = []; + if (item.itemType === "signRequest") { + const req = item as SignRequestSummary; + parts.push( + t("sharedSign.fromOwner", "From {{owner}}", { + owner: req.ownerUsername ?? t("unknown", "Unknown"), + }), + ); + if (req.dueDate) { + parts.push( + t("sharedSign.due", "Due {{date}}", { + date: new Date(req.dueDate).toLocaleDateString(), + }), + ); + } + } else { + const s = item as SessionSummary; + parts.push( + t("sharedSign.createdOn", "Created {{date}}", { + date: new Date(s.createdAt).toLocaleDateString(), + }), + ); + if ( + s.signedCount !== undefined && + s.participantCount !== undefined + ) { + parts.push( + t("sharedSign.signedCount", "{{signed}}/{{total}} signed", { + signed: s.signedCount, + total: s.participantCount, + }), + ); + } + } + const subtitle = parts.join(" • "); + return ( + onItemClick(item)} + style={{ cursor: "pointer" }} + > + + + + {item.documentName} + + + {subtitle} + + + + {status.label} + + + + ); + })} + + )} +
+ ); +}; + +export default SharedSign; diff --git a/frontend/editor/src/core/tools/Sign.tsx b/frontend/editor/src/core/tools/Sign.tsx index 0e64342506..30ab2bfd5a 100644 --- a/frontend/editor/src/core/tools/Sign.tsx +++ b/frontend/editor/src/core/tools/Sign.tsx @@ -7,6 +7,7 @@ const Sign = createStampTool({ defaultSignatureSource: "canvas", defaultSignatureType: "canvas", enableApplyAction: true, + enableSharedSigning: true, }); export default Sign; diff --git a/frontend/editor/src/core/tools/stamp/createStampTool.tsx b/frontend/editor/src/core/tools/stamp/createStampTool.tsx index 9e30a87520..1d2bd06d89 100644 --- a/frontend/editor/src/core/tools/stamp/createStampTool.tsx +++ b/frontend/editor/src/core/tools/stamp/createStampTool.tsx @@ -18,6 +18,9 @@ import { useSignature } from "@app/contexts/SignatureContext"; import { useFileContext } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { flattenSignatures } from "@app/utils/signatureFlattening"; +import SharedSigningLauncher from "@app/components/shared/signing/SharedSigningLauncher"; +import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; export type StampToolConfig = { toolId: ToolId; @@ -26,6 +29,7 @@ export type StampToolConfig = { defaultSignatureSource?: SignatureSource; defaultSignatureType?: SignParameters["signatureType"]; enableApplyAction?: boolean; + enableSharedSigning?: boolean; }; const STAMP_TOOL_DEFAULT_SOURCES: SignatureSource[] = [ @@ -43,6 +47,7 @@ export const createStampTool = (config: StampToolConfig) => { defaultSignatureSource, defaultSignatureType, enableApplyAction = false, + enableSharedSigning = false, } = config; const StampTool = (props: BaseToolProps) => { @@ -76,6 +81,7 @@ export const createStampTool = (config: StampToolConfig) => { activeFileIndex, setActiveFileIndex, } = useViewer(); + const groupSigningEnabled = useGroupSigningEnabled(); const base = useBaseTool( toolId, useSignParameters, @@ -249,6 +255,16 @@ export const createStampTool = (config: StampToolConfig) => { /> ), }); + + // Optional step: send the document to others to sign instead. + if (enableSharedSigning && groupSigningEnabled) { + steps.push({ + title: translateTool("sharedSigningRequest", "Request signatures"), + isCollapsed: false, + onCollapsedClick: undefined, + content: , + }); + } } return steps; @@ -260,6 +276,7 @@ export const createStampTool = (config: StampToolConfig) => { isCollapsed: base.operation.files.length > 0, }, steps: getSteps(), + preview: , review: { isVisible: false, operation: base.operation, diff --git a/frontend/editor/src/core/types/toolId.ts b/frontend/editor/src/core/types/toolId.ts index 5286aec080..1fc231b427 100644 --- a/frontend/editor/src/core/types/toolId.ts +++ b/frontend/editor/src/core/types/toolId.ts @@ -14,6 +14,7 @@ export type ToolKind = "regular" | "super" | "link"; export const CORE_REGULAR_TOOL_IDS = [ "certSign", "sign", + "sharedSign", "addText", "addPassword", "removePassword", diff --git a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx index 3e181b19a6..a6e406a410 100644 --- a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx @@ -13,7 +13,6 @@ import { Box, } from "@mantine/core"; import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; -import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection"; import { useSignature } from "@app/contexts/SignatureContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { @@ -1265,8 +1264,6 @@ const SignSettings = ({ {translate("applySignatures", "Apply Signatures")} )} - - ); }; diff --git a/scripts/ignore_translation.toml b/scripts/ignore_translation.toml index 58b50aa6a6..36b697aeb7 100644 --- a/scripts/ignore_translation.toml +++ b/scripts/ignore_translation.toml @@ -111,7 +111,6 @@ ignore = [ 'plan.pro.name', 'plan.upgrade', 'pro', - 'quickAccess.reader', 'settings.connection.mode.saas', 'settings.connection.server', 'settings.licensingAnalytics.audit', @@ -220,7 +219,6 @@ ignore = [ 'provider.oauth2.keycloak.scope', 'provider.saml2.name', 'provider.saml2.scope', - 'quickAccess.accessRoleEditor', 'redact.modeSelector.manual', 'settings.configuration.endpoints', 'settings.connection.mode.saas', @@ -513,7 +511,6 @@ ignore = [ 'provider.oauth2.keycloak.scopes.label', 'provider.saml2.name', 'provider.saml2.scope', - 'quickAccess.accessEmailPlaceholder', 'settings.connection.mode.saas', 'settings.securityAuth.telegram', 'showJS.tags',