add file share to the top workbench bar and add shared signing (#6715)

This commit is contained in:
EthanHealy01
2026-06-30 22:14:49 +01:00
committed by GitHub
parent 276eb8f2a7
commit 7ab30d2629
65 changed files with 3539 additions and 7975 deletions
@@ -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);
@@ -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
@@ -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 {
@@ -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;
}
@@ -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<String, Object> 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<String, Object> 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;
@@ -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);
}
}
@@ -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);
});
}
// -------------------------------------------------------------------------
@@ -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<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(workflowParticipantRepository).save(captor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> cert =
(Map<String, Object>)
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");
@@ -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"
@@ -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"
+6
View File
@@ -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",
@@ -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({
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
<FolderFileContextProvider>
{children}
</FolderFileContextProvider>
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
<SigningOverlayProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
<FolderFileContextProvider>
{children}
</FolderFileContextProvider>
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SigningOverlayProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
@@ -277,7 +277,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
opened={modalOpen}
onClose={closeModal}
title={t("sign.canvas.modalTitle", "Draw your signature")}
size="auto"
size="min(48rem, 92vw)"
centered
>
<Stack gap="md">
@@ -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 <FileManagerView />;
}
// 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 (
<Viewer
sidebarsVisible={sidebarsVisible}
setSidebarsVisible={setSidebarsVisible}
previewFile={signingOverlay.file}
signaturePreviews={signingOverlay.signaturePreviews}
signaturePreviewsReadOnly={signingOverlay.signaturePreviewsReadOnly}
signaturePlacementMode={signingOverlay.signaturePlacementMode}
signaturePlacementData={signingOverlay.signaturePlacementData}
signaturePlacementType={signingOverlay.signaturePlacementType}
onSignaturePreviewsChange={signingOverlay.onSignaturePreviewsChange}
signatureOverlayApiRef={signingOverlay.signatureOverlayApiRef}
/>
);
}
if (activeFiles.length === 0) {
return <LandingPage />;
}
@@ -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;
}
File diff suppressed because it is too large Load Diff
@@ -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<ShareManagementModalProps> = ({
opened={opened}
onClose={onClose}
centered
title={t("storageShare.manageTitle", "Manage Sharing")}
title={
<Group gap="xs" wrap="nowrap">
<ShareIcon style={{ fontSize: 20 }} />
<Text fw={600} size="lg">
{t("storageShare.manageTitle", "Manage Sharing")}
</Text>
</Group>
}
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 }}
>
<Stack gap="lg">
<Stack gap={4}>
<Group justify="space-between" align="center" gap="md" mt="md">
<Text size="sm" c="dimmed">
{t(
"storageShare.manageDescription",
@@ -491,7 +504,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
{file.name}
</Text>
</Text>
</Stack>
</Group>
{errorMessage && (
<Alert
@@ -513,7 +526,10 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
</Alert>
)}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<SimpleGrid
cols={{ base: 1, md: shareLinksEnabled ? 2 : 1 }}
spacing="lg"
>
<Stack gap="lg">
{shareLinksEnabled && (
<Paper withBorder radius="md" p="md">
@@ -574,8 +590,9 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Text size="sm" fw={600}>
{t("storageShare.sharedUsersTitle", "Shared users")}
</Text>
<Group align="flex-end" gap="sm">
<Group align="flex-end" gap="sm" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
label={t("storageShare.usernameLabel", "Username or email")}
placeholder={t(
"storageShare.usernamePlaceholder",
@@ -29,6 +29,8 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { enforceExportPolicies } from "@app/services/policyExport";
import { downloadFile as downloadRaw } from "@app/services/downloadService";
@@ -107,6 +109,7 @@ export default function WorkbenchBar({
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const { sharingEnabled } = useSharingEnabled();
const viewerContext = React.useContext(ViewerContext);
const { selectors } = useFileState();
@@ -477,6 +480,15 @@ export default function WorkbenchBar({
{/* Right: Global buttons - export group left, close anchored right */}
<div className="workbench-bar-globals">
{/* Share (viewer only; opens the same modal as My Files "Manage sharing") */}
{currentView === "viewer" && sharingEnabled && (
<ViewerShareButton
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
}
/>
)}
{/* Print */}
{currentView === "viewer" &&
renderWithTooltip(
@@ -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<ActiveToolButtonProps> = ({
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<boolean>(false);
const [replayAnim, setReplayAnim] = useState<boolean>(false);
const [isBackHover, setIsBackHover] = useState<boolean>(false);
const prevKeyRef = useRef<string | null>(null);
const collapseTimeoutRef = useRef<number | null>(null);
const animTimeoutRef = useRef<number | null>(null);
const replayRafRef = useRef<number | null>(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 (
<>
<div
style={{ overflow: "visible" }}
className={`current-tool-slot ${indicatorVisible ? "visible" : ""} ${replayAnim ? "replay" : ""}`}
>
{indicatorTool && (
<div className="current-tool-content">
<div className="flex flex-col items-center gap-1">
<Tooltip
content={
isBackHover
? t("quickAccess.backToAllTools", "Back to all tools")
: indicatorTool.name
}
position={tooltipPosition}
arrow
maxWidth={140}
>
<ActionIcon
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
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",
}}
>
<span className="iconContainer">
{isBackHover ? (
<ArrowBackRoundedIcon sx={{ fontSize: "1.875rem" }} />
) : (
indicatorTool.icon
)}
</span>
</ActionIcon>
</Tooltip>
<FitText
as="span"
text={indicatorTool.name}
lines={3}
minimumFontScale={0.4}
className="button-text active current-tool-label"
/>
</div>
<Divider size="xs" className="current-tool-divider" />
</div>
)}
</div>
</>
);
};
export default ActiveToolButton;
@@ -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;
}
}
@@ -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";
};
@@ -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<TourStatePayload>;
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,
};
}
@@ -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 (
<div className="quick-access-popout__panel">
{loading ? (
<Center p="xl">
<Loader size="sm" />
</Center>
) : (
<>
{sessions.length === 0 ? (
<div className="quick-access-popout__section">
<Text size="sm" c="dimmed" ta="center" py="xl">
{t(
"quickAccess.noActiveSessions",
"No pending sign requests or active sessions",
)}
</Text>
</div>
) : (
<>
{sessions.map((session) => (
<div
key={`${session.itemType}-${session.sessionId}`}
className="quick-access-popout__sign-request-row"
onClick={() => onSessionClick(session)}
>
<div className="quick-access-popout__sign-request-info">
<div className="quick-access-popout__row-title">
{session.documentName}
</div>
<div className="quick-access-popout__row-subtitle">
{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`}
</>
)}
</div>
</div>
<div className="quick-access-popout__sign-request-badge">
<Badge
size="sm"
color={getStatusColor(
session.myStatus,
session.itemType,
session,
)}
>
{getStatusLabel(session)}
</Badge>
</div>
</div>
))}
</>
)}
</>
)}
</div>
);
};
export default ActiveSessionsPanel;
@@ -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 (
<div className="quick-access-popout__panel">
{loading ? (
<Center p="xl">
<Loader size="sm" />
</Center>
) : sessions.length === 0 ? (
<div className="quick-access-popout__section">
<Text size="sm" c="dimmed" ta="center" py="xl">
{t("quickAccess.noCompletedSessions", "No completed sessions")}
</Text>
</div>
) : (
<>
{sessions.map((session) => (
<div
key={`${session.itemType}-${session.sessionId}`}
className="quick-access-popout__sign-request-row"
onClick={() => onSessionClick(session)}
>
<div className="quick-access-popout__sign-request-info">
<div className="quick-access-popout__row-title">
{session.documentName}
</div>
<div className="quick-access-popout__row-subtitle">
{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`}
</>
)}
</div>
</div>
<div className="quick-access-popout__sign-request-badge">
<Badge
size="sm"
color={getStatusColor(session.myStatus, session.itemType)}
>
{getStatusLabel(session)}
</Badge>
</div>
</div>
))}
</>
)}
</div>
);
};
export default CompletedSessionsPanel;
@@ -47,9 +47,9 @@ const StepWrapper: React.FC<StepWrapperProps> = ({
: "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,
}}
@@ -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 (
<div className="quick-access-popout__panel">
{!hasValidFile ? (
<div className="quick-access-popout__section">
<Text size="sm" c="dimmed" ta="center" py="xl">
{t(
"quickAccess.selectSingleFileToRequest",
"Select a single PDF file to request signatures",
)}
</Text>
</div>
) : (
<>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">
{t("quickAccess.selectedFile", "File")}
</div>
<div className="quick-access-popout__row-title">
{selectedFiles[0]?.name ||
t("quickAccess.noFile", "No file loaded")}
</div>
</div>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">
{t("quickAccess.selectUsers", "Select users to sign")}
</div>
<UserSelector
value={selectedUserIds}
onChange={onSelectedUserIdsChange}
size="xs"
placeholder={t(
"quickAccess.selectUsersPlaceholder",
"Choose participants...",
)}
disabled={creating}
/>
</div>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">
{t("quickAccess.dueDate", "Due date (optional)")}
</div>
<input
type="date"
className="quick-access-popout__input"
value={dueDate}
onChange={(e) => onDueDateChange(e.target.value)}
disabled={creating}
/>
</div>
<div className="quick-access-popout__section">
<Switch
label={t(
"certSign.collab.sessionCreation.includeSummaryPage",
"Include Signature Summary Page",
)}
description={t(
"certSign.collab.sessionCreation.includeSummaryPageHelp",
"Add a summary page at the end with all signature details",
)}
checked={includeSummaryPage}
onChange={(e) =>
onIncludeSummaryPageChange(e.currentTarget.checked)
}
disabled={creating}
size="sm"
/>
</div>
</>
)}
</div>
);
};
export default CreateSessionPanel;
@@ -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 (
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t(
"sign.sharedSigningStepDesc",
"Send this document to others to sign instead of signing it yourself.",
)}
</Text>
<Button
fullWidth
variant="light"
leftSection={<GroupAddOutlinedIcon sx={{ fontSize: "1.1rem" }} />}
rightSection={
pendingCount > 0 ? (
<Badge size="sm" circle variant="filled" color="red">
{pendingCount}
</Badge>
) : undefined
}
onClick={() => handleToolSelect("sharedSign")}
>
{t("sign.sharedSigningOpen", "Open shared signing")}
</Button>
</Stack>
);
}
File diff suppressed because it is too large Load Diff
@@ -79,10 +79,7 @@ export const ConfigureSignatureDefaultsStep: React.FC<
{t("groupSigning.steps.back", "Back")}
</Button>
<Button onClick={onNext} disabled={disabled} style={{ flex: 1 }}>
{t(
"groupSigning.steps.configureDefaults.continue",
"Continue to Review",
)}
{t("groupSigning.steps.configureDefaults.continue", "Continue")}
</Button>
</Group>
</Stack>
@@ -56,7 +56,9 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
backgroundColor: "var(--mantine-color-default-hover)",
}}
>
<Text size="sm">{selectedFile.name}</Text>
<Text size="sm" style={{ overflowWrap: "anywhere" }}>
{selectedFile.name}
</Text>
{selectedFile.size && (
<Text size="xs" c="dimmed">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
@@ -177,9 +179,18 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
<Button
onClick={onSubmit}
disabled={disabled}
h="auto"
style={{ flex: 1 }}
leftSection={<SendIcon sx={{ fontSize: 16 }} />}
color="green"
styles={{
root: { paddingTop: 6, paddingBottom: 6 },
label: {
whiteSpace: "normal",
textAlign: "center",
lineHeight: 1.2,
},
}}
>
{t("groupSigning.steps.review.send", "Send Signing Requests")}
</Button>
@@ -49,8 +49,8 @@ export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({
<PictureAsPdfIcon
sx={{ fontSize: 32, color: "var(--mantine-color-red-6)" }}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={600}>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{selectedFile?.name}
</Text>
{selectedFile?.size && (
@@ -63,10 +63,7 @@ export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({
</div>
<Button onClick={onNext} fullWidth>
{t(
"groupSigning.steps.selectDocument.continue",
"Continue to Participant Selection",
)}
{t("groupSigning.steps.selectDocument.continue", "Continue")}
</Button>
</>
)}
@@ -64,10 +64,7 @@ export const SelectParticipantsStep: React.FC<SelectParticipantsStepProps> = ({
disabled={!hasParticipants || disabled}
style={{ flex: 1 }}
>
{t(
"groupSigning.steps.selectParticipants.continue",
"Continue to Signature Settings",
)}
{t("groupSigning.steps.selectParticipants.continue", "Continue")}
</Button>
</Group>
</Stack>
@@ -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
}
/>
))}
</div>
@@ -212,6 +230,9 @@ const ToolPicker = ({
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
badgeCount={
id === "sharedSign" ? signingBadgeCount : undefined
}
/>
))}
</div>
@@ -183,20 +183,34 @@ export const CertificateSelector: React.FC<CertificateSelectorProps> = ({
/>
)}
{/* PEM */}
{/* PEM — private key and certificate are two separate files */}
{uploadFormat === "PEM" && (
<Stack gap="xs">
<FileUploadButton
file={privateKeyFile ?? undefined}
onChange={(file) => onPrivateKeyFileChange(file || null)}
accept=".pem,.der,.key"
disabled={disabled}
placeholder={t(
"certSign.choosePrivateKey",
"Choose Private Key File",
)}
/>
{privateKeyFile && (
<Stack gap="sm">
<Stack gap={4}>
<Text size="xs" fw={600}>
{t(
"certSign.pemPrivateKeyLabel",
"Private key (.pem / .key)",
)}
</Text>
<FileUploadButton
file={privateKeyFile ?? undefined}
onChange={(file) => onPrivateKeyFileChange(file || null)}
accept=".pem,.der,.key"
disabled={disabled}
placeholder={t(
"certSign.choosePrivateKey",
"Choose Private Key File",
)}
/>
</Stack>
<Stack gap={4}>
<Text size="xs" fw={600}>
{t(
"certSign.pemCertificateLabel",
"Certificate (.pem / .crt)",
)}
</Text>
<FileUploadButton
file={certFile ?? undefined}
onChange={(file) => onCertFileChange(file || null)}
@@ -207,7 +221,7 @@ export const CertificateSelector: React.FC<CertificateSelectorProps> = ({
"Choose Certificate File",
)}
/>
)}
</Stack>
</Stack>
)}
@@ -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<void>;
onLoadSignedPdf: () => Promise<void>;
onAddParticipants: (
userIds: number[],
defaultReason?: string,
) => Promise<void>;
onRemoveParticipant: (participantId: number) => Promise<void>;
onDelete: () => Promise<void>;
onBack: () => void;
onRefresh: () => Promise<void>;
}
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<AnnotationAPI | null>(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<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;
}, [session.participants]);
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
{/* Top Control Bar */}
<Paper
p="sm"
shadow="sm"
style={{ flexShrink: 0, zIndex: Z_INDEX_FULLSCREEN_SURFACE }}
>
<Group justify="space-between">
<Group gap="md">
<Button
leftSection={<ArrowBackIcon />}
variant="subtle"
onClick={onBack}
size="sm"
>
{t(
"certSign.collab.sessionDetail.backToList",
"Back to Sessions",
)}
</Button>
<Divider orientation="vertical" />
<Stack gap={2}>
<Group gap="sm">
<Text
size="sm"
fw={600}
style={{ maxWidth: isPhone ? "140px" : undefined }}
truncate={isPhone ? "end" : undefined}
>
{session.documentName}
</Text>
<Badge
size="sm"
color={session.finalized ? "green" : "blue"}
variant="light"
>
{session.finalized
? t("certSign.collab.sessionList.finalized", "Finalized")
: t("certSign.collab.sessionList.active", "Active")}
</Badge>
</Group>
{!isPhone && (
<Text size="xs" c="dimmed">
{session.ownerEmail &&
`${t("certSign.collab.sessionDetail.owner", "Owner")}: ${session.ownerEmail}`}
{session.ownerEmail && " • "}
{new Date(session.createdAt).toLocaleDateString()}
</Text>
)}
</Stack>
</Group>
<Group gap="xs">
{/* Zoom Controls — hidden on phone (pinch-to-zoom available) */}
{!isPhone && (
<Button.Group>
<Button
variant="default"
size="sm"
onClick={() => annotationApiRef.current?.zoomOut()}
title={t("viewer.zoomOut", "Zoom out")}
>
<ZoomOutIcon fontSize="small" />
</Button>
<Button
variant="default"
size="sm"
onClick={() => annotationApiRef.current?.resetZoom()}
title={t("viewer.resetZoom", "Reset zoom")}
>
<ZoomOutMapIcon fontSize="small" />
</Button>
<Button
variant="default"
size="sm"
onClick={() => annotationApiRef.current?.zoomIn()}
title={t("viewer.zoomIn", "Zoom in")}
>
<ZoomInIcon fontSize="small" />
</Button>
</Button.Group>
)}
{/* Delete Session Button */}
{!session.finalized && (
<Button
leftSection={<DeleteIcon />}
color="red"
variant="outline"
onClick={() => setDeleteModalOpen(true)}
size="sm"
>
{t(
"certSign.collab.sessionDetail.deleteSession",
"Delete Session",
)}
</Button>
)}
</Group>
</Group>
</Paper>
{/* Main Content Area */}
{isPhone ? (
// Phone: single-panel view — all three panels stay mounted (CSS display:none preserves state)
<div style={{ position: "relative", flex: 1, overflow: "hidden" }}>
<Paper
p="md"
shadow="sm"
style={{
display: mobilePanel === "participants" ? "flex" : "none",
flexDirection: "column",
position: "absolute",
inset: 0,
overflowY: "auto",
}}
>
<ParticipantListPanel
participants={session.participants}
finalized={session.finalized}
onRemove={handleRemoveParticipant}
/>
</Paper>
<div
style={{
display: mobilePanel === "pdf" ? "flex" : "none",
position: "absolute",
inset: 0,
}}
>
<LocalEmbedPDFWithAnnotations
ref={annotationApiRef}
file={pdfFile ?? undefined}
initialSignatures={wetSignaturePreviews}
readOnly={true}
/>
</div>
<Paper
p="md"
shadow="sm"
style={{
display: mobilePanel === "actions" ? "flex" : "none",
flexDirection: "column",
position: "absolute",
inset: 0,
overflowY: "auto",
}}
>
<SessionActionsPanel
session={session}
onAddParticipants={() => setAddParticipantsModalOpen(true)}
onFinalize={handleFinalize}
onLoadSignedPdf={handleLoadSignedPdf}
finalizing={finalizing}
loadingPdf={loadingPdf}
/>
</Paper>
</div>
) : (
// Desktop/tablet: three-column flex layout
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Left Panel - Participants */}
<Paper
p="md"
shadow="sm"
style={{
width: "280px",
flexShrink: 0,
overflowY: "auto",
borderRight: "1px solid var(--mantine-color-gray-3)",
}}
>
<ParticipantListPanel
participants={session.participants}
finalized={session.finalized}
onRemove={handleRemoveParticipant}
/>
</Paper>
{/* Center - PDF Viewer */}
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<LocalEmbedPDFWithAnnotations
ref={annotationApiRef}
file={pdfFile ?? undefined}
initialSignatures={wetSignaturePreviews}
readOnly={true}
/>
</div>
{/* Right Panel - Session Actions */}
<Paper
p="md"
shadow="sm"
style={{
width: "320px",
flexShrink: 0,
overflowY: "auto",
borderLeft: "1px solid var(--mantine-color-gray-3)",
}}
>
<SessionActionsPanel
session={session}
onAddParticipants={() => setAddParticipantsModalOpen(true)}
onFinalize={handleFinalize}
onLoadSignedPdf={handleLoadSignedPdf}
finalizing={finalizing}
loadingPdf={loadingPdf}
/>
</Paper>
</div>
)}
{/* Phone bottom navigation */}
{isPhone && (
<Paper
shadow="sm"
style={{
flexShrink: 0,
borderTop: "1px solid var(--mantine-color-gray-3)",
}}
>
<SegmentedControl
fullWidth
value={mobilePanel}
onChange={(v) => 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"),
},
]}
/>
</Paper>
)}
{/* Add Participants Modal */}
<AddParticipantsFlow
opened={addParticipantsModalOpen}
onClose={() => setAddParticipantsModalOpen(false)}
onSubmit={handleAddParticipants}
/>
{/* Delete Confirmation Modal */}
<Modal
opened={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
title={t(
"certSign.collab.sessionDetail.deleteSession",
"Delete Session",
)}
>
<Stack gap="md">
<Text>
{t(
"certSign.collab.sessionDetail.deleteConfirm",
"Are you sure? This cannot be undone.",
)}
</Text>
<Group justify="flex-end">
<Button variant="subtle" onClick={() => setDeleteModalOpen(false)}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" onClick={handleDelete} loading={deleting}>
{t("delete", "Delete")}
</Button>
</Group>
</Stack>
</Modal>
</div>
);
};
export default SessionDetailWorkbenchView;
@@ -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;
}
}
@@ -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<string | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement | null>(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 (
<Box
component="div"
style={{
width: 72,
height: 28,
backgroundColor: "#ffffff",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 8px",
overflow: "hidden",
}}
>
<Text
size="sm"
style={{
fontFamily: sig.fontFamily,
color: sig.textColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{sig.signerName}
</Text>
</Box>
);
}
return (
<Box
component="div"
style={{
width: 72,
height: 28,
backgroundColor: "#ffffff",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2px 6px",
}}
>
<Box
component="img"
src={sig.dataUrl}
alt={
sig.label ||
t("certSign.collab.signRequest.saved.defaultLabel", "Signature")
}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
display: "block",
}}
/>
</Box>
);
},
[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<string> => {
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<HTMLInputElement>) => {
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" ? (
<div
className={styles.signingPreviewFrame}
style={{
fontFamily: signatureConfig.fontFamily ?? "Helvetica",
color: signatureConfig.textColor ?? "#000000",
fontSize: 14,
lineHeight: 1.1,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 160,
}}
>
{(signatureConfig.signerName ?? "").trim() ||
t("certSign.collab.signRequest.preview.textFallback", "Signature")}
</div>
) : (
<div className={styles.signingPreviewFrame}>
{signatureConfig.signatureData ? (
<img
src={signatureConfig.signatureData}
alt={t(
"certSign.collab.signRequest.preview.imageAlt",
"Selected signature",
)}
style={{
maxWidth: 160,
maxHeight: 32,
objectFit: "contain",
display: "block",
}}
/>
) : (
<Text size="xs" c="dimmed">
{t("certSign.collab.signRequest.preview.missing", "No preview")}
</Text>
)}
</div>
);
return (
<div className={styles.signStrip} data-open={visible ? "true" : "false"}>
<div className={styles.signStripInner}>
<div className={styles.signingRow}>
<div className={styles.signingLeft}>
<Text size="sm" fw={700} className={styles.signingTitle}>
{t("certSign.collab.signRequest.signingTitle", "Signing")}
</Text>
</div>
<div className={styles.signingCenter} aria-hidden="true" />
<div className={styles.signingMode}>
<SegmentedControl
className={styles.signStripModeRadio}
value={placementMode ? "place" : "move"}
onChange={(value) => {
if (value === "place") resumePlacement();
else pausePlacement();
}}
data={[
{
value: "place",
label: (
<Group gap={6} wrap="nowrap">
<DrawIcon sx={{ fontSize: "1.1rem" }} />
<span>
{t(
"certSign.collab.signRequest.mode.place",
"Place Signature",
)}
</span>
</Group>
),
},
{
value: "move",
label: (
<Group gap={6} wrap="nowrap">
<OpenWithIcon sx={{ fontSize: "1.1rem" }} />
<span>
{t(
"certSign.collab.signRequest.mode.move",
"Move Signature",
)}
</span>
</Group>
),
},
]}
size="xs"
radius="xl"
aria-label={t(
"certSign.collab.signRequest.mode.title",
"Sign or move mode",
)}
/>
</div>
<div className={styles.signingRight}>
<Menu
withinPortal
position="bottom-end"
shadow="md"
styles={{
dropdown: {
paddingTop: 0,
paddingBottom: 0,
},
}}
>
<Menu.Target>
<button
type="button"
className={styles.signingPreviewButton}
aria-label={t(
"certSign.collab.signRequest.changeSignature",
"Change signature",
)}
>
{previewNode}
</button>
</Menu.Target>
<Menu.Dropdown>
{sortedSavedSignatures.length ? (
sortedSavedSignatures.map((sig) => (
<Menu.Item
key={sig.id}
onClick={() => applySavedSignature(sig)}
>
<Group gap="sm" wrap="nowrap" justify="space-between">
{renderSavedSignaturePreview(sig)}
<ActionIcon
component="div"
size="xs"
color="red"
variant="subtle"
onClick={(e) => {
e.stopPropagation();
removeSignature(sig.id);
}}
aria-label={t(
"certSign.collab.signRequest.saved.delete",
"Delete signature",
)}
>
<CloseIcon sx={{ fontSize: "0.9rem" }} />
</ActionIcon>
</Group>
</Menu.Item>
))
) : (
<Menu.Item disabled>
{t(
"certSign.collab.signRequest.saved.none",
"No saved signatures",
)}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
onClick={() => handleCreateSignature("canvas")}
disabled={isAtCapacity}
>
<Group gap="xs">
<DrawIcon sx={{ fontSize: "1rem" }} />
<span>
{t("certSign.collab.signRequest.modeTabs.draw", "Draw")}
</span>
</Group>
</Menu.Item>
<Menu.Item
onClick={() => handleCreateSignature("text")}
disabled={isAtCapacity}
>
<Group gap="xs">
<TextFieldsIcon sx={{ fontSize: "1rem" }} />
<span>
{t("certSign.collab.signRequest.modeTabs.text", "Type")}
</span>
</Group>
</Menu.Item>
<Menu.Item
onClick={() => handleCreateSignature("image")}
disabled={isAtCapacity}
>
<Group gap="xs">
<ImageIcon sx={{ fontSize: "1rem" }} />
<span>
{t(
"certSign.collab.signRequest.modeTabs.image",
"Upload",
)}
</span>
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
<div className={styles.signingDivider} aria-hidden="true" />
<button
type="button"
className={`${styles.iconButton} ${styles.signStripMobileDelete}`}
onClick={onDeleteSelected}
disabled={!hasSelectedAnnotation}
aria-label={t(
"certSign.collab.signRequest.deleteSelected",
"Delete selected signature",
)}
title={t(
"certSign.collab.signRequest.deleteSelected",
"Delete selected signature",
)}
>
<DeleteOutlineIcon sx={{ fontSize: "1.2rem" }} />
</button>
<button
type="button"
className={`${styles.iconButton} ${styles.iconTextButton}`}
onClick={onComplete}
disabled={!canComplete}
aria-label={t(
"certSign.collab.signRequest.completeAndSign",
"Complete & Sign",
)}
title={t(
"certSign.collab.signRequest.completeAndSign",
"Complete & Sign",
)}
>
<span className={styles.actionIcon}>
<CheckIcon sx={{ fontSize: "1.2rem" }} />
</span>
<span className={styles.actionLabel}>
{t(
"certSign.collab.signRequest.completeAndSign",
"Complete & Sign",
)}
</span>
</button>
</div>
</div>
</div>
{/* Draw Signature — auto-opens its inner canvas modal directly */}
{createSignatureType === "canvas" && (
<DrawingCanvas
autoOpen
selectedColor={canvasColor}
penSize={canvasPenSize}
penSizeInput={canvasPenSizeInput}
onColorSwatchClick={() => 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 */}
<Modal
opened={createSignatureType === "text"}
onClose={handleCancelCreate}
title={t("certSign.collab.signRequest.modeTabs.text", "Type Signature")}
size="md"
withinPortal
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"certSign.collab.signRequest.text.modalHint",
"Enter your name, then click Continue to place it on the PDF.",
)}
</Text>
<TextInputWithFont
text={textSignerName}
onTextChange={setTextSignerName}
fontFamily={textFontFamily}
onFontFamilyChange={setTextFontFamily}
fontSize={textFontSize}
onFontSizeChange={setTextFontSize}
textColor={textColor}
onTextColorChange={setTextColor}
label={t(
"certSign.collab.signRequest.text.label",
"Signature Text",
)}
placeholder={t(
"certSign.collab.signRequest.text.placeholder",
"Enter your name...",
)}
fontLabel={t("certSign.collab.signRequest.text.fontLabel", "Font")}
fontSizeLabel={t(
"certSign.collab.signRequest.text.fontSizeLabel",
"Size",
)}
fontSizePlaceholder={t(
"certSign.collab.signRequest.text.fontSizePlaceholder",
"16",
)}
colorLabel={t(
"certSign.collab.signRequest.text.colorLabel",
"Color",
)}
/>
<Group justify="flex-end">
<Button variant="subtle" onClick={handleCancelCreate}>
{t("cancel", "Cancel")}
</Button>
<Button onClick={handleSaveText} disabled={!textSignerName.trim()}>
{t("certSign.collab.signRequest.canvas.continue", "Continue")}
</Button>
</Group>
</Stack>
</Modal>
{canvasColorPickerOpen && (
<ColorPicker
isOpen={canvasColorPickerOpen}
onClose={() => setCanvasColorPickerOpen(false)}
selectedColor={canvasColor}
onColorChange={setCanvasColor}
title={t(
"certSign.collab.signRequest.canvas.colorPickerTitle",
"Choose stroke colour",
)}
/>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleImageSelected}
/>
</div>
);
}
@@ -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<void>;
onDecline: () => Promise<void>;
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<AnnotationAPI | null>(null);
// Signature state - start with default config if user can sign
const [signatureConfig, setSignatureConfig] = useState<SignParameters | null>(
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 (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
{/* Top Control Bar */}
<Paper
p="sm"
shadow="sm"
style={{
flexShrink: 0,
zIndex: Z_INDEX_FULLSCREEN_SURFACE,
position: "relative",
}}
>
<Group
justify="space-between"
style={{ flexWrap: isPhone ? "wrap" : "nowrap" }}
>
<Group gap="md">
<LocalIcon
icon="signature-rounded"
width="1.5rem"
height="1.5rem"
/>
<div>
<Text
size="sm"
fw={600}
style={{ maxWidth: isPhone ? "180px" : undefined }}
truncate={isPhone ? "end" : undefined}
>
{signRequest.documentName}
</Text>
{!isPhone && (
<Text size="xs" c="dimmed">
{t("certSign.collab.signRequest.from", "From")}:{" "}
{signRequest.ownerUsername} •{" "}
{new Date(signRequest.createdAt).toLocaleDateString()}
</Text>
)}
</div>
</Group>
<Group
gap="xs"
style={{ width: isPhone ? "100%" : undefined }}
justify={isPhone ? "flex-end" : undefined}
>
<Button
variant="light"
size="sm"
leftSection={<FolderOpenIcon fontSize="small" />}
onClick={handleAddToActiveFiles}
style={{
backgroundColor: "var(--landing-inner-paper-bg)",
color: "var(--btn-open-file)",
border: "1px solid var(--landing-inner-paper-border)",
}}
>
{t(
"certSign.collab.signRequest.addToFiles",
"Add to Active Files",
)}
</Button>
{signRequest.myStatus !== "SIGNED" &&
signRequest.myStatus !== "DECLINED" && (
<Button
variant="light"
color="red"
size="sm"
leftSection={<CancelIcon fontSize="small" />}
onClick={handleDecline}
loading={declining}
>
{t("certSign.collab.signRequest.decline", "Decline Request")}
</Button>
)}
{!isPhone && (
<>
<Divider orientation="vertical" />
<Button.Group>
<Button
variant="subtle"
size="sm"
onClick={() => annotationApiRef.current?.zoomOut()}
title={t("viewer.zoomOut", "Zoom out")}
>
<ZoomOutIcon fontSize="small" />
</Button>
<Button
variant="subtle"
size="sm"
onClick={() => annotationApiRef.current?.resetZoom()}
title={t("viewer.resetZoom", "Reset zoom")}
>
<ZoomOutMapIcon fontSize="small" />
</Button>
<Button
variant="subtle"
size="sm"
onClick={() => annotationApiRef.current?.zoomIn()}
title={t("viewer.zoomIn", "Zoom in")}
>
<ZoomInIcon fontSize="small" />
</Button>
</Button.Group>
</>
)}
<Divider orientation="vertical" />
<CloseButton
size="md"
onClick={onBack}
title={t(
"certSign.collab.signRequest.backToList",
"Back to Sign Requests",
)}
/>
</Group>
</Group>
</Paper>
{/* Sign Controls Strip - always shown when user can sign */}
{canSign && signControlsVisible && (
<SignControlsStrip
visible={signControlsVisible}
placementMode={placementMode}
onPlacementModeChange={setPlacementMode}
onSignatureSelected={handleSignatureSelected}
onComplete={handleOpenCertificateModal}
canComplete={previewCount > 0}
signatureConfig={signatureConfig}
hasSelectedAnnotation={hasSelectedAnnotation}
onDeleteSelected={handleDeleteSelected}
/>
)}
{/* PDF Viewer (full width) */}
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<LocalEmbedPDFWithAnnotations
ref={annotationApiRef}
file={pdfFile}
onAnnotationChange={() => {}}
placementMode={placementMode}
signatureData={signatureConfig?.signatureData}
signatureType={signatureConfig?.signatureType}
onPlaceSignature={handlePlaceSignature}
onPreviewCountChange={setPreviewCount}
/>
</div>
{/* Certificate Configuration Modal */}
{canSign && (
<CertificateConfigModal
opened={certificateModalOpen}
onClose={() => setCertificateModalOpen(false)}
onSign={handleSign}
signatureCount={previewCount}
disabled={signing}
defaultReason={signRequest.reason || ""}
defaultLocation={signRequest.location || ""}
/>
)}
</div>
);
};
export default SignRequestWorkbenchView;
@@ -72,7 +72,7 @@ export const SessionActionsPanel: React.FC<SessionActionsPanelProps> = ({
<Button
leftSection={<CheckCircleIcon />}
color={allSigned ? "green" : "orange"}
color={allSigned ? "green.6" : "orange"}
fullWidth
onClick={onFinalize}
loading={finalizing}
@@ -0,0 +1,269 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
Group,
Badge,
Button,
Divider,
Modal,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteIcon from "@mui/icons-material/Delete";
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";
import type { SigningDetailData } from "@app/hooks/signing/useSigningSessionController";
interface SessionDetailPanelProps {
data: SigningDetailData;
}
/** Sidebar controls for an owned signing session (status, participants, actions); the document + read-only overlays render in the main Viewer. */
export const SessionDetailPanel = ({ data }: SessionDetailPanelProps) => {
const { t } = useTranslation();
const {
session,
onFinalize,
onLoadSignedPdf,
onAddParticipants,
onRemoveParticipant,
onDelete,
onBack,
onRefresh,
} = data;
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 while the session is active.
useEffect(() => {
if (session.finalized) return;
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 the modal can handle its 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);
} 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);
}
};
return (
<Stack gap="md" p="md" h="100%" style={{ minHeight: 0 }}>
<Button
leftSection={<ArrowBackIcon fontSize="small" />}
variant="subtle"
size="sm"
onClick={onBack}
justify="flex-start"
px={6}
style={{ alignSelf: "flex-start" }}
>
{t("certSign.collab.sessionDetail.backToList", "Back to Sessions")}
</Button>
<Stack gap={4}>
<Group gap="sm" wrap="nowrap">
<Text size="sm" fw={600} truncate style={{ flex: 1, minWidth: 0 }}>
{session.documentName}
</Text>
<Badge
size="sm"
color={session.finalized ? "green" : "blue"}
variant="light"
>
{session.finalized
? t("certSign.collab.sessionList.finalized", "Finalized")
: t("certSign.collab.sessionList.active", "Active")}
</Badge>
</Group>
{(session.ownerEmail || session.createdAt) && (
<Text size="xs" c="dimmed">
{session.ownerEmail &&
`${t("certSign.collab.sessionDetail.owner", "Owner")}: ${session.ownerEmail}`}
{session.ownerEmail && session.createdAt && " • "}
{session.createdAt &&
new Date(session.createdAt).toLocaleDateString()}
</Text>
)}
</Stack>
<Divider />
{/* Participants — scrollable, bounded so actions stay visible */}
<div style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
<ParticipantListPanel
participants={session.participants}
finalized={session.finalized}
onRemove={handleRemoveParticipant}
/>
</div>
<Divider />
<SessionActionsPanel
session={session}
onAddParticipants={() => setAddParticipantsModalOpen(true)}
onFinalize={handleFinalize}
onLoadSignedPdf={handleLoadSignedPdf}
finalizing={finalizing}
loadingPdf={loadingPdf}
/>
{!session.finalized && (
<Button
leftSection={<DeleteIcon fontSize="small" />}
color="red"
variant="light"
fullWidth
onClick={() => setDeleteModalOpen(true)}
>
{t("certSign.collab.sessionDetail.deleteSession", "Delete Session")}
</Button>
)}
{/* Add Participants Modal */}
<AddParticipantsFlow
opened={addParticipantsModalOpen}
onClose={() => setAddParticipantsModalOpen(false)}
onSubmit={handleAddParticipants}
/>
{/* Delete Confirmation Modal */}
<Modal
opened={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
title={t(
"certSign.collab.sessionDetail.deleteSession",
"Delete Session",
)}
>
<Stack gap="md">
<Text>
{t(
"certSign.collab.sessionDetail.deleteConfirm",
"Are you sure? This cannot be undone.",
)}
</Text>
<Group justify="flex-end">
<Button variant="subtle" onClick={() => setDeleteModalOpen(false)}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" onClick={handleDelete} loading={deleting}>
{t("delete", "Delete")}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default SessionDetailPanel;
@@ -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<SignatureType, SavedSignature["type"]> = {
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<SignatureType>("draw");
const [createSignature, setCreateSignature] = useState<string | null>(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 (
<Box
style={{
width: 72,
height: 28,
backgroundColor: "#ffffff",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 8px",
overflow: "hidden",
}}
>
<Text
size="sm"
style={{
fontFamily: sig.fontFamily,
color: sig.textColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{sig.signerName}
</Text>
</Box>
);
}
return (
<Box
style={{
width: 72,
height: 28,
backgroundColor: "#ffffff",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2px 6px",
}}
>
<Box
component="img"
src={sig.dataUrl}
alt={
sig.label ||
t("certSign.collab.signRequest.saved.defaultLabel", "Signature")
}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
display: "block",
}}
/>
</Box>
);
},
[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" ? (
<Text
size="sm"
style={{
fontFamily: signatureConfig.fontFamily ?? "Helvetica",
color: signatureConfig.textColor ?? "#000000",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{(signatureConfig.signerName ?? "").trim() ||
t("certSign.collab.signRequest.preview.textFallback", "Signature")}
</Text>
) : signatureConfig.signatureData ? (
<img
src={signatureConfig.signatureData}
alt={t(
"certSign.collab.signRequest.preview.imageAlt",
"Selected signature",
)}
style={{
maxHeight: 32,
maxWidth: "100%",
objectFit: "contain",
display: "block",
}}
/>
) : (
<Group gap={4} wrap="nowrap" c="var(--mantine-color-blue-6)">
<DrawIcon sx={{ fontSize: "0.95rem" }} />
<Text size="xs" fw={600}>
{t("certSign.collab.signRequest.preview.create", "Add signature")}
</Text>
</Group>
);
return (
<Stack gap="sm">
<Text size="sm" fw={700}>
{t("certSign.collab.signRequest.signingTitle", "Signing")}
</Text>
{/* Current signature + change menu */}
<Menu withinPortal position="bottom" shadow="md" width="target">
<Menu.Target>
<Button
variant="default"
fullWidth
justify="space-between"
rightSection={<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />}
styles={{ label: { flex: 1, overflow: "hidden" } }}
aria-label={t(
"certSign.collab.signRequest.changeSignature",
"Change signature",
)}
>
<Box
style={{
display: "flex",
alignItems: "center",
gap: 8,
minWidth: 0,
backgroundColor: "#ffffff",
borderRadius: 8,
padding: "2px 8px",
minHeight: 28,
}}
>
{previewNode}
</Box>
</Button>
</Menu.Target>
<Menu.Dropdown>
{sortedSavedSignatures.length ? (
sortedSavedSignatures.map((sig) => (
<Menu.Item key={sig.id} onClick={() => applySavedSignature(sig)}>
<Group gap="sm" wrap="nowrap" justify="space-between">
{renderSavedSignaturePreview(sig)}
<ActionIcon
component="div"
size="xs"
color="red"
variant="subtle"
onClick={(e) => {
e.stopPropagation();
removeSignature(sig.id);
}}
aria-label={t(
"certSign.collab.signRequest.saved.delete",
"Delete signature",
)}
>
<CloseIcon sx={{ fontSize: "0.9rem" }} />
</ActionIcon>
</Group>
</Menu.Item>
))
) : (
<Menu.Item disabled>
{t(
"certSign.collab.signRequest.saved.none",
"No saved signatures",
)}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
leftSection={<AddIcon sx={{ fontSize: "1rem" }} />}
onClick={openCreateModal}
disabled={isAtCapacity}
>
{t(
"certSign.collab.signRequest.createNewSignature",
"Create New Signature",
)}
</Menu.Item>
</Menu.Dropdown>
</Menu>
{/* Place vs. move */}
<SegmentedControl
fullWidth
value={placementMode ? "place" : "move"}
onChange={(value) => onPlacementModeChange(value === "place")}
data={[
{
value: "place",
label: (
<Group gap={6} wrap="nowrap" justify="center">
<DrawIcon sx={{ fontSize: "1.1rem" }} />
<span>
{t("certSign.collab.signRequest.mode.place", "Place")}
</span>
</Group>
),
},
{
value: "move",
label: (
<Group gap={6} wrap="nowrap" justify="center">
<OpenWithIcon sx={{ fontSize: "1.1rem" }} />
<span>
{t("certSign.collab.signRequest.mode.move", "Move")}
</span>
</Group>
),
},
]}
size="xs"
radius="xl"
aria-label={t(
"certSign.collab.signRequest.mode.title",
"Sign or move mode",
)}
/>
<Button
variant="light"
color="red"
leftSection={<DeleteOutlineIcon sx={{ fontSize: "1.1rem" }} />}
onClick={onDeleteSelected}
disabled={!hasSelectedAnnotation}
fullWidth
>
{t(
"certSign.collab.signRequest.deleteSelected",
"Delete selected signature",
)}
</Button>
<Button
leftSection={<CheckIcon sx={{ fontSize: "1.1rem" }} />}
onClick={onComplete}
disabled={!canComplete}
fullWidth
>
{t("certSign.collab.signRequest.completeAndSign", "Complete & Sign")}
</Button>
{/* Create signature — reuses the shared wet-signature creation flow */}
<Modal
opened={createOpen}
onClose={() => setCreateOpen(false)}
title={t(
"certSign.collab.signRequest.createNewSignature",
"Create New Signature",
)}
size="md"
withinPortal
>
<SignatureCreationStep
signatureType={createType}
onSignatureTypeChange={setCreateType}
signature={createSignature}
onSignatureChange={setCreateSignature}
signatureText={textValue}
fontFamily={fontFamily}
fontSize={fontSize}
textColor={textColor}
onSignatureTextChange={setTextValue}
onFontFamilyChange={setFontFamily}
onFontSizeChange={setFontSize}
onTextColorChange={setTextColor}
onNext={handleUseCreated}
nextLabel={t(
"certSign.collab.signRequest.useSignature",
"Use signature",
)}
/>
</Modal>
</Stack>
);
}
@@ -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<SignatureOverlayAPI | null>(null);
const [signatureConfig, setSignatureConfig] = useState<SignParameters | null>(
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 (
<Stack gap="md" p="md">
<Button
leftSection={<ArrowBackIcon fontSize="small" />}
variant="subtle"
size="sm"
onClick={onBack}
justify="flex-start"
px={6}
style={{ alignSelf: "flex-start" }}
>
{t("certSign.collab.signRequest.backToList", "Back to Sign Requests")}
</Button>
<Stack gap={2}>
<Text size="sm" fw={600} truncate>
{signRequest.documentName}
</Text>
<Text size="xs" c="dimmed">
{t("certSign.collab.signRequest.from", "From")}:{" "}
{signRequest.ownerUsername} •{" "}
{new Date(signRequest.createdAt).toLocaleDateString()}
</Text>
</Stack>
<Divider />
{canSign && signControlsVisible && (
<>
<SignControlsPanel
placementMode={placementMode}
onPlacementModeChange={setPlacementMode}
onSignatureSelected={setSignatureConfig}
onComplete={handleOpenCertificateModal}
canComplete={previewCount > 0}
signatureConfig={signatureConfig}
hasSelectedAnnotation={hasSelectedAnnotation}
onDeleteSelected={handleDeleteSelected}
/>
<Divider />
</>
)}
<Button
variant="light"
leftSection={<FolderOpenIcon fontSize="small" />}
onClick={handleAddToActiveFiles}
fullWidth
style={{
backgroundColor: "var(--landing-inner-paper-bg)",
color: "var(--btn-open-file)",
border: "1px solid var(--landing-inner-paper-border)",
}}
>
{t("certSign.collab.signRequest.addToFiles", "Add to Active Files")}
</Button>
{signRequest.myStatus !== "SIGNED" &&
signRequest.myStatus !== "DECLINED" && (
<Button
variant="light"
color="red"
leftSection={<CancelIcon fontSize="small" />}
onClick={handleDecline}
loading={declining}
fullWidth
>
{t("certSign.collab.signRequest.decline", "Decline Request")}
</Button>
)}
{canSign && (
<CertificateConfigModal
opened={certificateModalOpen}
onClose={() => setCertificateModalOpen(false)}
onSign={handleSign}
signatureCount={previewCount}
disabled={signing}
defaultReason={signRequest.reason || ""}
defaultLocation={signRequest.location || ""}
/>
)}
</Stack>
);
};
export default SignRequestPanel;
@@ -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<SignatureCreationStepProps> = ({
onFontSizeChange,
onTextColorChange,
onNext,
nextLabel,
disabled = false,
}) => {
const { t } = useTranslation();
@@ -89,10 +92,11 @@ export const SignatureCreationStep: React.FC<SignatureCreationStepProps> = ({
)}
<Button onClick={onNext} disabled={!hasSignature || disabled} fullWidth>
{t(
"certSign.collab.signRequest.steps.continue",
"Continue to Certificate Selection",
)}
{nextLabel ??
t(
"certSign.collab.signRequest.steps.continue",
"Continue to Certificate Selection",
)}
</Button>
</Stack>
);
@@ -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")}
</Button>
)}
<SuggestedToolsSection />
</Stack>
);
};
@@ -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<ToolButtonProps> = ({
@@ -47,6 +48,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
hasStars = false,
showDescription = false,
onUnavailableClick,
badgeCount,
}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
@@ -183,6 +185,16 @@ const ToolButton: React.FC<ToolButtonProps> = ({
{t("toolPanel.alpha", "Alpha")}
</Badge>
)}
{typeof badgeCount === "number" && badgeCount > 0 && (
<Badge
size="sm"
variant="filled"
color="red"
style={{ flexShrink: 0 }}
>
{badgeCount}
</Badge>
)}
{usesCloud && !visuallyUnavailable && <CloudBadge />}
</div>
{showDescription && tool.description && (
@@ -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<SignatureOverlayAPI | null>;
}
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<HTMLDivElement>(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) */}
<FormSaveBar
@@ -1,4 +1,10 @@
import React, { useEffect, useMemo, useState } from "react";
import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react";
import { createPluginRegistration } from "@embedpdf/core";
import type { PluginRegistry } from "@embedpdf/core";
import { EmbedPDF } from "@embedpdf/core/react";
@@ -70,7 +76,10 @@ import type {
SignatureAPI,
AnnotationAPI,
HistoryAPI,
SignaturePreview,
SignatureOverlayAPI,
} from "@app/components/viewer/viewerTypes";
import { SignaturePreviewLayer } from "@app/components/viewer/SignaturePreviewLayer";
import { ExportAPIBridge } from "@app/components/viewer/ExportAPIBridge";
import { BookmarkAPIBridge } from "@app/components/viewer/BookmarkAPIBridge";
import { AttachmentAPIBridge } from "@app/components/viewer/AttachmentAPIBridge";
@@ -121,6 +130,21 @@ interface LocalEmbedPDFProps {
isSignMode?: boolean;
/** Controls CSS filter applied only to rendered PDF canvas tiles */
pdfRenderMode?: "normal" | "dark" | "sepia";
// ── Signature overlay (opt-in; all default off) ──────────────────────────
/** Read-only / interactive signature preview overlays to render per page. */
signaturePreviews?: SignaturePreview[];
/** If true, previews are display-only (cannot be moved, resized, or deleted). */
signaturePreviewsReadOnly?: boolean;
/** When true (and not read-only), clicking a page places a new preview. */
signaturePlacementMode?: boolean;
/** Base64 PNG used for placement and the cursor ghost preview. */
signaturePlacementData?: string;
/** Signature type assigned to newly placed previews. */
signaturePlacementType?: "canvas" | "image" | "text";
/** Emits the full updated preview array whenever overlays change. */
onSignaturePreviewsChange?: (previews: SignaturePreview[]) => void;
/** Imperative handle for reading/clearing/deleting signature previews. */
signatureOverlayApiRef?: React.RefObject<SignatureOverlayAPI | null>;
}
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<string>("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<string | null>(
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 && (
<SignaturePreviewLayer
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
previews={localSignaturePreviews}
readOnly={signaturePreviewsReadOnly}
placementMode={signaturePlacementMode}
placementData={signaturePlacementData}
placementType={signaturePlacementType}
onChange={handleSignaturePreviewsChange}
selectedId={selectedSignatureId}
onSelect={setSelectedSignatureId}
/>
)}
</div>
</PagePointerProvider>
</Rotate>
File diff suppressed because it is too large Load Diff
@@ -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<HTMLDivElement>) => {
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 (
<div
style={{
position: "absolute",
inset: 0,
cursor: !readOnly && placementMode ? "crosshair" : "default",
// Let clicks fall through to the page except in placement mode, where we
// need to capture click-to-place. Individual previews opt back in below.
pointerEvents: !readOnly && placementMode ? "auto" : "none",
}}
onMouseMove={
!readOnly && placementMode && placementData
? (e) => {
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 (
<Tooltip
key={preview.id}
label={preview.participantName ?? ""}
position="top"
withArrow
disabled={!preview.participantName}
>
<div
style={{
position: "absolute",
left: preview.x * pageWidth,
top: preview.y * pageHeight,
width: preview.width * pageWidth,
height: preview.height * pageHeight,
border: readOnly
? `1px dashed ${colorOpacity(0.4)}`
: `2px solid ${color}`,
boxShadow: readOnly ? "none" : `0 0 10px ${colorOpacity(0.5)}`,
cursor: readOnly ? "default" : "move",
zIndex: Z_INDEX_SIGNATURE_OVERLAY,
backgroundColor: readOnly
? "transparent"
: "rgba(255, 255, 255, 0.1)",
pointerEvents: "auto",
}}
>
{/* Delete button - only show when not read-only */}
{!readOnly && (
<ActionIcon
size="sm"
radius="xl"
variant="filled"
color="red"
style={{
position: "absolute",
top: -10,
right: -10,
zIndex: Z_INDEX_SIGNATURE_OVERLAY_DELETE,
pointerEvents: "auto",
boxShadow: "0 1px 4px rgba(0,0,0,0.25)",
border: "2px solid white",
}}
onClick={(e) => {
e.stopPropagation();
onChange(previews.filter((p) => p.id !== preview.id));
if (selectedId === preview.id) onSelect?.(null);
}}
aria-label={t("viewer.signature.delete", "Delete signature")}
>
<CloseIcon style={{ fontSize: "0.8rem" }} />
</ActionIcon>
)}
<div
style={{
width: "100%",
height: "100%",
pointerEvents: readOnly ? "none" : "auto",
}}
onPointerDown={
readOnly
? undefined
: (e) => {
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);
}
}
>
<img
src={preview.signatureData}
alt="Signature preview"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
pointerEvents: "none",
}}
/>
{/* Resize handles */}
{!readOnly &&
RESIZE_HANDLES.map((handle) => (
<div
key={handle.position}
data-resize-handle="true"
style={{
position: "absolute",
width: 8,
height: 8,
backgroundColor: color,
border: "1px solid white",
cursor: handle.cursor,
zIndex: Z_INDEX_SIGNATURE_OVERLAY_HANDLE,
...("top" in handle && handle.top !== undefined
? { top: handle.top }
: {}),
...("bottom" in handle && handle.bottom !== undefined
? { bottom: handle.bottom }
: {}),
...("left" in handle && handle.left !== undefined
? { left: handle.left }
: {}),
...("right" in handle && handle.right !== undefined
? { right: handle.right }
: {}),
}}
onPointerDown={(e) => {
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);
}}
/>
))}
</div>
</div>
</Tooltip>
);
})}
{/* Hover preview: ghost signature following cursor in placement mode */}
{!readOnly && placementMode && placementData && cursorPos && (
<img
src={placementData}
alt=""
style={{
position: "absolute",
left: Math.max(0, Math.min(cursorPos.x - 75, pageWidth - 150)),
top: Math.max(0, Math.min(cursorPos.y - 37.5, pageHeight - 75)),
width: 150,
height: 75,
opacity: 0.6,
pointerEvents: "none",
objectFit: "contain",
boxShadow:
"0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)",
borderRadius: "4px",
zIndex: Z_INDEX_SIGNATURE_OVERLAY + 1,
}}
/>
)}
</div>
);
});
@@ -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();
@@ -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<StirlingFileStub | null>(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 (
<>
<Tooltip
content={label}
position="bottom"
offset={6}
arrow
portalTarget={document.body}
>
<div className="workbench-bar-tooltip-wrapper">
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={handleClick}
disabled={isDisabled}
aria-label={label}
>
<ShareIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</div>
</Tooltip>
<Modal
opened={confirmOpen}
onClose={() => {
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 }}
>
<Stack>
<Stack ta="center" p="md" gap="xs">
<Text size="lg" fw={500}>
{t(
"storageShare.saveFirstHeading",
"Save this file to the server to share it",
)}
</Text>
<Text size="sm" c="dimmed">
{t(
"storageShare.saveFirstBody",
"Sharing works on files saved to the server. We'll save it to your files, then continue to sharing.",
)}
</Text>
</Stack>
<Group justify="center" gap="sm">
<Button
variant="light"
color="var(--mantine-color-gray-8)"
w={BUTTON_WIDTH}
onClick={() => setConfirmOpen(false)}
disabled={saving}
>
{t("cancel", "Cancel")}
</Button>
<Button
variant="filled"
w={BUTTON_WIDTH}
leftSection={<CloudUploadIcon fontSize="small" />}
onClick={handleSaveAndShare}
loading={saving}
>
{t("storageShare.saveAndShare", "Save to server & share")}
</Button>
</Group>
</Stack>
</Modal>
{shareStub && (
<ShareManagementModal
opened={shareOpen}
onClose={() => setShareOpen(false)}
file={shareStub}
/>
)}
</>
);
}
@@ -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;
@@ -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<SignatureOverlayAPI | null>;
}
interface SigningOverlayContextValue {
overlay: SigningOverlay | null;
setOverlay: React.Dispatch<React.SetStateAction<SigningOverlay | null>>;
}
const SigningOverlayContext = createContext<
SigningOverlayContextValue | undefined
>(undefined);
export function SigningOverlayProvider({
children,
}: {
children: React.ReactNode;
}) {
const [overlay, setOverlay] = useState<SigningOverlay | null>(null);
const value = useMemo<SigningOverlayContextValue>(
() => ({ overlay, setOverlay }),
[overlay],
);
return (
<SigningOverlayContext.Provider value={value}>
{children}
</SigningOverlayContext.Provider>
);
}
export function useSigningOverlay(): SigningOverlayContextValue {
const ctx = useContext(SigningOverlayContext);
if (!ctx) {
throw new Error(
"useSigningOverlay must be used within a SigningOverlayProvider",
);
}
return ctx;
}
@@ -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: <GroupAddOutlinedIcon sx={{ fontSize: "1.5rem" }} />,
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: (
<LocalIcon
@@ -0,0 +1,42 @@
import { useSyncExternalStore } from "react";
import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled";
import { useSigningSessions } from "@app/hooks/signing/useSigningSessions";
import {
getLastSeenSignedCount,
getSigningSeenVersion,
subscribeSigningSeen,
} from "@app/services/signingSeenStore";
/**
* Count that drives the Shared Signing badge: sign requests awaiting the user's
* signature, plus the user's own sessions that gained new signatures since they
* last opened them. 0 when group signing is disabled. Polls in the background
* while enabled.
*/
export function useSigningBadgeCount(): number {
const enabled = useGroupSigningEnabled();
const { signRequests, mySessions } = useSigningSessions({
enabled,
autoRefreshInterval: enabled ? 60000 : 0,
});
// Re-read the per-session "seen" markers whenever they change.
useSyncExternalStore(
subscribeSigningSeen,
getSigningSeenVersion,
getSigningSeenVersion,
);
const incoming = signRequests.filter(
(request) =>
request.myStatus !== "SIGNED" && request.myStatus !== "DECLINED",
).length;
const ownerUpdates = mySessions.filter(
(session) =>
!session.finalized &&
session.signedCount > getLastSeenSignedCount(session.sessionId),
).length;
return incoming + ownerUpdates;
}
@@ -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<void>;
onLoadSignedPdf: () => Promise<void>;
onAddParticipants: (
userIds: number[],
defaultReason?: string,
) => Promise<void>;
onRemoveParticipant: (participantId: number) => Promise<void>;
onDelete: () => Promise<void>;
onBack: () => void;
onRefresh: () => Promise<void>;
}
/** Data the sign-request sidebar panel needs to render and act. */
export interface SigningRequestData {
signRequest: SignRequestDetail;
pdfFile: File;
onSign: (certificateData: FormData) => Promise<void>;
onDecline: () => Promise<void>;
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<SigningView>("list");
const [detailData, setDetailData] = useState<SigningDetailData | null>(null);
const [requestData, setRequestData] = useState<SigningRequestData | null>(
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<SessionDetail>(
`/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<SignRequestDetail>(
`/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<SessionDetail>(
`/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<boolean> => {
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,
};
}
@@ -32,39 +32,52 @@ export const useSigningSessions = (
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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<SignRequestSummary[]>(
"/api/v1/security/cert-sign/sign-requests",
),
apiClient.get<SessionSummary[]>("/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<SignRequestSummary[]>(
"/api/v1/security/cert-sign/sign-requests",
),
apiClient.get<SessionSummary[]>(
"/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);
@@ -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<any>;
}
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,
},
}),
[],
);
};
@@ -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<string, number>;
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;
}
@@ -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<Tab>("active");
const [filters, setFilters] = useState<string[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
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<SessionItem[]>(
() =>
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<SessionItem[]>(
() =>
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 (
<Stack p="md">
<Alert
color="yellow"
title={t("sharedSign.disabledTitle", "Not enabled")}
>
{t(
"sharedSign.disabledBody",
"Collaborative signing isn't enabled on this server.",
)}
</Alert>
</Stack>
);
}
if (controller.view === "detail" && controller.detailData) {
return <SessionDetailPanel data={controller.detailData} />;
}
if (controller.view === "request" && controller.requestData) {
return <SignRequestPanel data={controller.requestData} />;
}
if (showCreate) {
return (
<Stack p="md" gap="md">
<Group>
<Button
variant="subtle"
size="compact-sm"
leftSection={<ArrowBackIcon sx={{ fontSize: "1rem" }} />}
onClick={() => setShowCreate(false)}
>
{t("sharedSign.backToSessions", "Back to sessions")}
</Button>
</Group>
<CreateSessionFlow
selectedFiles={selectedFiles}
selectedUserIds={selectedUserIds}
onSelectedUserIdsChange={setSelectedUserIds}
dueDate={dueDate}
onDueDateChange={setDueDate}
creating={controller.creating}
onSubmit={(settings) => {
void controller
.createSession(settings, selectedUserIds, dueDate)
.then((ok) => {
if (ok) {
setShowCreate(false);
setSelectedUserIds([]);
setDueDate("");
changeTab("active");
}
});
}}
/>
</Stack>
);
}
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 (
<Stack p="md" gap="md">
<SegmentedControl
fullWidth
value={tab}
onChange={(value) => changeTab(value as Tab)}
data={[
{ label: t("sharedSign.tab.active", "Active"), value: "active" },
{
label: t("sharedSign.tab.completed", "Completed"),
value: "completed",
},
]}
/>
<Button
variant="light"
leftSection={<AddIcon sx={{ fontSize: "1.1rem" }} />}
onClick={() => setShowCreate(true)}
>
{t("sharedSign.newRequest", "Request signatures")}
</Button>
<Chip.Group multiple value={filters} onChange={setFilters}>
<Group gap="xs">
{filterOptions.map((f) => (
<Chip key={f.key} value={f.key} size="xs" radius="sm">
{f.label}
</Chip>
))}
</Group>
</Chip.Group>
{controller.loading && items.length === 0 ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : items.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{tab === "active"
? t(
"sharedSign.empty.active",
"No pending sign requests or active sessions",
)
: t("sharedSign.empty.completed", "No completed sessions")}
</Text>
) : (
<Stack gap="sm">
{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 (
<Paper
key={`${item.itemType}-${item.sessionId}`}
withBorder
radius="md"
p="sm"
onClick={() => onItemClick(item)}
style={{ cursor: "pointer" }}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{item.documentName}
</Text>
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
</Stack>
<Badge size="sm" color={status.color} variant="light">
{status.label}
</Badge>
</Group>
</Paper>
);
})}
</Stack>
)}
</Stack>
);
};
export default SharedSign;
+1
View File
@@ -7,6 +7,7 @@ const Sign = createStampTool({
defaultSignatureSource: "canvas",
defaultSignatureType: "canvas",
enableApplyAction: true,
enableSharedSigning: true,
});
export default Sign;
@@ -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: <SharedSigningLauncher />,
});
}
}
return steps;
@@ -260,6 +276,7 @@ export const createStampTool = (config: StampToolConfig) => {
isCollapsed: base.operation.files.length > 0,
},
steps: getSteps(),
preview: <SuggestedToolsSection />,
review: {
isVisible: false,
operation: base.operation,
+1
View File
@@ -14,6 +14,7 @@ export type ToolKind = "regular" | "super" | "link";
export const CORE_REGULAR_TOOL_IDS = [
"certSign",
"sign",
"sharedSign",
"addText",
"addPassword",
"removePassword",
@@ -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")}
</Button>
)}
<SuggestedToolsSection />
</Stack>
);
};
-3
View File
@@ -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',