add file share to the top workbench bar and add shared signing (#6715)
This commit is contained in:
+3
-5
@@ -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);
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+22
@@ -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 {
|
||||
|
||||
+6
-5
@@ -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;
|
||||
}
|
||||
|
||||
+137
-11
@@ -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;
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+70
@@ -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"
|
||||
|
||||
@@ -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
+1
-4
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user