mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
170f317f11 | ||
|
|
5f553ece11 |
+106
@@ -6,6 +6,7 @@ import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
@@ -260,6 +261,111 @@ public class EmailService {
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a document signing invitation to an external/guest user.
|
||||
*
|
||||
* @param to the recipient email address
|
||||
* @param ownerName the display name of the session owner
|
||||
* @param documentName the name of the document to be signed
|
||||
* @param signingUrl the full URL of the guest signing page
|
||||
* @param expiresAt human-readable expiry date/time string
|
||||
* @param message optional personal message from the owner (may be null)
|
||||
* @throws MessagingException if sending fails
|
||||
*/
|
||||
@Async
|
||||
public void sendSigningInvitationEmail(
|
||||
String to,
|
||||
String ownerName,
|
||||
String documentName,
|
||||
String signingUrl,
|
||||
String expiresAt,
|
||||
String message)
|
||||
throws MessagingException {
|
||||
// Escape all user-supplied values before embedding in HTML
|
||||
String safeOwner = HtmlUtils.htmlEscape(ownerName != null ? ownerName : "");
|
||||
String safeDoc = HtmlUtils.htmlEscape(documentName != null ? documentName : "");
|
||||
// Only allow http/https URLs in the signing link to prevent javascript: injection
|
||||
String safeUrl =
|
||||
(signingUrl != null
|
||||
&& (signingUrl.startsWith("https://")
|
||||
|| signingUrl.startsWith("http://")))
|
||||
? signingUrl
|
||||
: "#";
|
||||
String safeUrlText = HtmlUtils.htmlEscape(safeUrl);
|
||||
|
||||
String subject = "Please sign: " + (documentName != null ? documentName : "");
|
||||
|
||||
String messageSection =
|
||||
(message != null && !message.isBlank())
|
||||
? """
|
||||
<div style="background-color: #f8f9fa; border-left: 4px solid #6c757d; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-style: italic; color: #555;">"%s"</p>
|
||||
</div>
|
||||
"""
|
||||
.formatted(HtmlUtils.htmlEscape(message))
|
||||
: "";
|
||||
|
||||
String expirySection =
|
||||
(expiresAt != null && !expiresAt.isBlank())
|
||||
? """
|
||||
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; color: #856404; font-size: 14px;"><strong>⚠ Important:</strong> This signing link will expire on %s.</p>
|
||||
</div>
|
||||
"""
|
||||
.formatted(HtmlUtils.htmlEscape(expiresAt))
|
||||
: "";
|
||||
|
||||
String body =
|
||||
"""
|
||||
<html><body style="margin: 0; padding: 0;">
|
||||
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
|
||||
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
|
||||
<!-- Logo -->
|
||||
<div style="text-align: center; padding: 20px; background-color: #222;">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div style="padding: 30px; color: #333;">
|
||||
<h2 style="color: #222; margin-top: 0;">You have been asked to sign a document</h2>
|
||||
<p>Hi there,</p>
|
||||
<p><strong>%s</strong> has requested your signature on:</p>
|
||||
<!-- Document Name Box -->
|
||||
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-size: 16px;"><strong>📄 %s</strong></p>
|
||||
</div>
|
||||
%s
|
||||
<!-- CTA Button -->
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Review and Sign Document</a>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
|
||||
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
|
||||
%s
|
||||
</div>
|
||||
%s
|
||||
<p style="font-size: 13px; color: #888;">No account is required. Click the button above to review the document and apply your signature.</p>
|
||||
<p>If you did not expect this request, you can safely ignore this email.</p>
|
||||
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
|
||||
© 2025 Stirling PDF. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(
|
||||
safeOwner,
|
||||
safeDoc,
|
||||
messageSection,
|
||||
safeUrl,
|
||||
safeUrlText,
|
||||
expirySection);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
|
||||
@Async
|
||||
public void sendPasswordChangedNotification(
|
||||
String to, String username, String newPassword, String loginUrl)
|
||||
|
||||
+59
-13
@@ -2,8 +2,11 @@ package stirling.software.proprietary.workflow.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -24,6 +27,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@@ -134,7 +138,7 @@ public class WorkflowParticipantController {
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<ParticipantResponse> submitSignature(
|
||||
@ModelAttribute SignatureSubmissionRequest request) {
|
||||
@ModelAttribute SignatureSubmissionRequest request, HttpServletRequest httpRequest) {
|
||||
|
||||
workflowSessionService.ensureSigningEnabled();
|
||||
|
||||
@@ -169,7 +173,8 @@ public class WorkflowParticipantController {
|
||||
|
||||
try {
|
||||
// Build metadata map with certificate and wet signature data
|
||||
Map<String, Object> metadata = buildSubmissionMetadata(request);
|
||||
Map<String, Object> metadata =
|
||||
buildSubmissionMetadata(request, participant, httpRequest);
|
||||
participant.setParticipantMetadata(metadata);
|
||||
|
||||
// Update status to SIGNED
|
||||
@@ -369,15 +374,27 @@ public class WorkflowParticipantController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds metadata map from signature submission request. Includes certificate submission and
|
||||
* wet signature data.
|
||||
* Builds metadata map from signature submission request. Includes certificate submission, wet
|
||||
* signature data, and an audit trail entry for compliance traceability.
|
||||
*/
|
||||
private Map<String, Object> buildSubmissionMetadata(SignatureSubmissionRequest request)
|
||||
private Map<String, Object> buildSubmissionMetadata(
|
||||
SignatureSubmissionRequest request,
|
||||
WorkflowParticipant participant,
|
||||
HttpServletRequest httpRequest)
|
||||
throws IOException {
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
|
||||
// Validate certificate before storing — throws 400 if invalid, expired, or wrong password
|
||||
if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) {
|
||||
// Default to GUEST_CERT for external (non-registered) participants that did not
|
||||
// supply their own certificate.
|
||||
String effectiveCertType = request.getCertType();
|
||||
if (effectiveCertType == null && participant.getUser() == null) {
|
||||
effectiveCertType = "GUEST_CERT";
|
||||
}
|
||||
|
||||
// Validate uploaded certificate before storing — skip for GUEST_CERT (no upload)
|
||||
if (effectiveCertType != null
|
||||
&& !"SERVER".equalsIgnoreCase(effectiveCertType)
|
||||
&& !"GUEST_CERT".equalsIgnoreCase(effectiveCertType)) {
|
||||
byte[] keystoreBytes = null;
|
||||
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
|
||||
keystoreBytes = request.getP12File().getBytes();
|
||||
@@ -386,16 +403,20 @@ public class WorkflowParticipantController {
|
||||
}
|
||||
if (keystoreBytes != null) {
|
||||
certificateSubmissionValidator.validateAndExtractInfo(
|
||||
keystoreBytes, request.getCertType(), request.getPassword());
|
||||
keystoreBytes, effectiveCertType, request.getPassword());
|
||||
}
|
||||
}
|
||||
|
||||
// Add certificate submission if provided
|
||||
if (request.getCertType() != null) {
|
||||
// Add certificate submission if provided (or defaulted)
|
||||
if (effectiveCertType != null) {
|
||||
Map<String, Object> certSubmission = new HashMap<>();
|
||||
certSubmission.put("certType", request.getCertType());
|
||||
certSubmission.put(
|
||||
"password", metadataEncryptionService.encrypt(request.getPassword()));
|
||||
certSubmission.put("certType", effectiveCertType);
|
||||
// GUEST_CERT passwords are derived at finalization time; no password to store
|
||||
String encryptedPassword =
|
||||
"GUEST_CERT".equalsIgnoreCase(effectiveCertType)
|
||||
? null
|
||||
: metadataEncryptionService.encrypt(request.getPassword());
|
||||
certSubmission.put("password", encryptedPassword);
|
||||
certSubmission.put("showSignature", request.getShowSignature());
|
||||
certSubmission.put("pageNumber", request.getPageNumber());
|
||||
certSubmission.put("location", request.getLocation());
|
||||
@@ -437,6 +458,31 @@ public class WorkflowParticipantController {
|
||||
metadata.put("wetSignatures", wetSigs);
|
||||
}
|
||||
|
||||
// Audit trail for compliance traceability (IP is hashed, not stored raw)
|
||||
Map<String, Object> auditTrail = new HashMap<>();
|
||||
auditTrail.put("ipHash", hashIp(httpRequest.getRemoteAddr()));
|
||||
String userAgent = httpRequest.getHeader("User-Agent");
|
||||
auditTrail.put(
|
||||
"userAgent",
|
||||
userAgent != null && userAgent.length() > 500
|
||||
? userAgent.substring(0, 500)
|
||||
: userAgent);
|
||||
auditTrail.put("submittedAt", Instant.now().toString());
|
||||
auditTrail.put("email", participant.getEmail());
|
||||
metadata.put("auditTrail", auditTrail);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/** Returns a SHA-256 Base64-encoded digest of an IP address for privacy-safe audit logging. */
|
||||
private String hashIp(String ip) {
|
||||
if (ip == null) return null;
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(ip.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(hash);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package stirling.software.proprietary.workflow.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.KeyStore;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Security;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.asn1.x509.GeneralNames;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Generates ephemeral (in-memory) PKCS12 keystores for guest/external signers. The generated
|
||||
* certificate includes the signer's email address as a Subject Alternative Name (rfc822Name),
|
||||
* providing industry-standard traceability. Guest keystores are never persisted to the database.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class GuestCertificateService {
|
||||
|
||||
private static final String KEYSTORE_ALIAS = "stirling-pdf-guest-cert";
|
||||
private static final int VALIDITY_DAYS = 365;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
static {
|
||||
if (Security.getProvider("BC") == null) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new in-memory PKCS12 keystore for a guest signer identified by email. The X.509
|
||||
* certificate includes: - CN = sanitized email (no special chars) - O = Stirling-PDF Guest -
|
||||
* SAN rfc822Name = raw email (industry-standard signer identity field)
|
||||
*
|
||||
* @param email the guest signer's email address
|
||||
* @return an in-memory PKCS12 KeyStore ready for use in PDF signing
|
||||
*/
|
||||
public KeyStore generateGuestKeyStore(String email) throws Exception {
|
||||
log.debug(
|
||||
"Generating guest certificate for external signer (domain: {})",
|
||||
email.contains("@") ? email.substring(email.indexOf('@')) : "<no-domain>");
|
||||
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
KeyPair keyPair = keyPairGenerator.generateKeyPair();
|
||||
|
||||
// Sanitize email for use in DN (remove chars that break X.500 parsing)
|
||||
String safeCn = email.replaceAll("[,=+<>#;\"\\\\]", "_");
|
||||
X500Name subject = new X500Name("CN=" + safeCn + ", O=Stirling-PDF Guest, C=US");
|
||||
|
||||
BigInteger serialNumber = new BigInteger(64, new SecureRandom());
|
||||
Date notBefore = new Date();
|
||||
Date notAfter =
|
||||
new Date(notBefore.getTime() + ((long) VALIDITY_DAYS * 24 * 60 * 60 * 1000));
|
||||
|
||||
JcaX509v3CertificateBuilder certBuilder =
|
||||
new JcaX509v3CertificateBuilder(
|
||||
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
|
||||
|
||||
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
|
||||
|
||||
// End-entity certificate, not a CA
|
||||
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||
|
||||
// Key usage for PDF digital signatures
|
||||
certBuilder.addExtension(
|
||||
Extension.keyUsage,
|
||||
true,
|
||||
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||
|
||||
// Extended key usage: emailProtection is the correct OID for email-identified signers
|
||||
certBuilder.addExtension(
|
||||
Extension.extendedKeyUsage,
|
||||
false,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_emailProtection));
|
||||
|
||||
// Subject Key Identifier
|
||||
certBuilder.addExtension(
|
||||
Extension.subjectKeyIdentifier,
|
||||
false,
|
||||
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// Authority Key Identifier (self-signed)
|
||||
certBuilder.addExtension(
|
||||
Extension.authorityKeyIdentifier,
|
||||
false,
|
||||
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// Subject Alternative Name: rfc822Name = email (industry-standard signer identity)
|
||||
GeneralName emailSan = new GeneralName(GeneralName.rfc822Name, email);
|
||||
GeneralNames subjectAltName = new GeneralNames(emailSan);
|
||||
certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAltName);
|
||||
|
||||
// Sign the certificate
|
||||
ContentSigner signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA")
|
||||
.setProvider("BC")
|
||||
.build(keyPair.getPrivate());
|
||||
|
||||
X509CertificateHolder certHolder = certBuilder.build(signer);
|
||||
X509Certificate cert =
|
||||
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
|
||||
|
||||
// Build in-memory PKCS12 keystore
|
||||
String password = generateGuestPassword(email);
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(null, null);
|
||||
keyStore.setKeyEntry(
|
||||
KEYSTORE_ALIAS,
|
||||
keyPair.getPrivate(),
|
||||
password.toCharArray(),
|
||||
new Certificate[] {cert});
|
||||
|
||||
// Round-trip through serialization so the keystore is fully initialised
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
keyStore.store(baos, password.toCharArray());
|
||||
KeyStore loaded = KeyStore.getInstance("PKCS12");
|
||||
loaded.load(new java.io.ByteArrayInputStream(baos.toByteArray()), password.toCharArray());
|
||||
|
||||
log.debug("Guest certificate generated (CN={}, SAN=rfc822Name:[email])", safeCn);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a deterministic password for a guest keystore. The password is a Base64-encoded
|
||||
* HMAC-SHA256 of the email using the application's secret key, truncated to 32 characters. Two
|
||||
* calls with the same email always return the same password.
|
||||
*
|
||||
* @param email the guest signer's email address
|
||||
* @return a deterministic password string
|
||||
*/
|
||||
public String generateGuestPassword(String email) {
|
||||
try {
|
||||
String rawKey = applicationProperties.getAutomaticallyGenerated().getKey();
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
// Fallback: SHA-256 of email if no application key available
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(email.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(hash).substring(0, 32);
|
||||
}
|
||||
|
||||
Mac hmac = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec keySpec =
|
||||
new SecretKeySpec(rawKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
hmac.init(keySpec);
|
||||
byte[] macBytes = hmac.doFinal(email.getBytes(StandardCharsets.UTF_8));
|
||||
String encoded = Base64.getEncoder().encodeToString(macBytes);
|
||||
// Truncate to 32 chars; use URL-safe replacement to avoid keystore password issues
|
||||
return encoded.replace("/", "_").replace("+", "-").substring(0, 32);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to derive guest keystore password", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -72,6 +72,9 @@ public class SigningFinalizationService {
|
||||
@Autowired(required = false)
|
||||
private final UserServerCertificateService userServerCertificateService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private final GuestCertificateService guestCertificateService;
|
||||
|
||||
// ===== PUBLIC API =====
|
||||
|
||||
/**
|
||||
@@ -891,6 +894,28 @@ public class SigningFinalizationService {
|
||||
"Failed to generate or retrieve user certificate: " + e.getMessage());
|
||||
}
|
||||
|
||||
case "GUEST_CERT":
|
||||
if (guestCertificateService == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Guest certificate service is not available");
|
||||
}
|
||||
if (participant.getEmail() == null || participant.getEmail().isBlank()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Guest certificate requires participant email address");
|
||||
}
|
||||
try {
|
||||
return guestCertificateService.generateGuestKeyStore(participant.getEmail());
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to generate guest certificate for {}: {}",
|
||||
participant.getEmail(),
|
||||
e.getMessage());
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate guest certificate: " + e.getMessage());
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid certificate type: " + certType);
|
||||
@@ -943,6 +968,12 @@ public class SigningFinalizationService {
|
||||
}
|
||||
}
|
||||
|
||||
if ("GUEST_CERT".equalsIgnoreCase(certType)
|
||||
&& guestCertificateService != null
|
||||
&& participant.getEmail() != null) {
|
||||
return guestCertificateService.generateGuestPassword(participant.getEmail());
|
||||
}
|
||||
|
||||
return submission.getPassword();
|
||||
}
|
||||
|
||||
|
||||
+61
@@ -8,11 +8,13 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -63,6 +65,9 @@ public class WorkflowSessionService {
|
||||
private final MetadataEncryptionService metadataEncryptionService;
|
||||
private final CertificateSubmissionValidator certificateSubmissionValidator;
|
||||
|
||||
@Autowired(required = false)
|
||||
private stirling.software.proprietary.security.service.EmailService emailService;
|
||||
|
||||
public void ensureSigningEnabled() {
|
||||
if (!applicationProperties.getStorage().isEnabled()
|
||||
|| !applicationProperties.getStorage().getSigning().isEnabled()) {
|
||||
@@ -230,6 +235,62 @@ public class WorkflowSessionService {
|
||||
|
||||
session.addParticipant(participant);
|
||||
participant = workflowParticipantRepository.save(participant);
|
||||
|
||||
// Send signing invitation email to external/guest participants
|
||||
if (request.getEmail() != null
|
||||
&& request.getUserId() == null
|
||||
&& emailService != null
|
||||
&& (request.isSendNotification())) {
|
||||
try {
|
||||
String signingUrl = buildSigningUrl(participant.getShareToken());
|
||||
String ownerName =
|
||||
session.getOwnerEmail() != null
|
||||
? session.getOwnerEmail()
|
||||
: "A Stirling PDF user";
|
||||
String expiresAt =
|
||||
participant.getExpiresAt() != null
|
||||
? participant.getExpiresAt().toString()
|
||||
: null;
|
||||
emailService.sendSigningInvitationEmail(
|
||||
participant.getEmail(),
|
||||
ownerName,
|
||||
session.getDocumentName(),
|
||||
signingUrl,
|
||||
expiresAt,
|
||||
session.getMessage());
|
||||
participant.setStatus(ParticipantStatus.NOTIFIED);
|
||||
workflowParticipantRepository.save(participant);
|
||||
log.info(
|
||||
"Sent signing invitation to external participant (domain: {})",
|
||||
emailDomain(participant.getEmail()));
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to send signing invitation email (domain: {}): {}",
|
||||
emailDomain(participant.getEmail()),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns only the domain portion of an email for privacy-safe logging. */
|
||||
private static String emailDomain(String email) {
|
||||
if (email == null) return "<null>";
|
||||
int at = email.indexOf('@');
|
||||
return at >= 0 ? email.substring(at) : "<no-domain>";
|
||||
}
|
||||
|
||||
/** Builds the guest signing URL for a share token using the current request context. */
|
||||
private String buildSigningUrl(String shareToken) {
|
||||
try {
|
||||
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||
.path("/sign/")
|
||||
.path(shareToken)
|
||||
.toUriString();
|
||||
} catch (Exception e) {
|
||||
// Fallback if called outside of a request context (e.g. in tests)
|
||||
log.debug("Could not determine base URL from request context, using relative path");
|
||||
return "/sign/" + shareToken;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+106
@@ -164,4 +164,110 @@ public class EmailServiceTest {
|
||||
assertEquals("Invalid Addresses", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendSigningInvitationEmail_sendsEmailWithDocumentNameInSubject()
|
||||
throws MessagingException {
|
||||
when(applicationProperties.getMail()).thenReturn(mailProperties);
|
||||
when(mailProperties.getFrom()).thenReturn("no-reply@stirling-software.com");
|
||||
MimeMessage mimeMessage = mock(MimeMessage.class);
|
||||
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||
|
||||
emailService.sendSigningInvitationEmail(
|
||||
"guest@example.com",
|
||||
"Alice Owner",
|
||||
"Contract 2025.pdf",
|
||||
"https://example.com/sign/abc-token",
|
||||
"2025-12-31",
|
||||
"Please review and sign");
|
||||
|
||||
verify(mailSender).send(mimeMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendSigningInvitationEmail_throwsForBlankRecipient() {
|
||||
assertThrows(
|
||||
MessagingException.class,
|
||||
() ->
|
||||
emailService.sendSigningInvitationEmail(
|
||||
"",
|
||||
"Owner",
|
||||
"Doc.pdf",
|
||||
"https://example.com/sign/tok",
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
// ── HTML injection / URL guard tests ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void sendSigningInvitationEmail_doesNotThrowForHtmlInDocumentName() throws MessagingException {
|
||||
when(applicationProperties.getMail()).thenReturn(mailProperties);
|
||||
when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com");
|
||||
MimeMessage mimeMessage = mock(MimeMessage.class);
|
||||
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||
|
||||
// Document name with injected HTML — must not cause an exception
|
||||
emailService.sendSigningInvitationEmail(
|
||||
"guest@example.com",
|
||||
"Alice",
|
||||
"<script>alert('xss')</script>Contract.pdf",
|
||||
"https://example.com/sign/tok",
|
||||
null,
|
||||
null);
|
||||
|
||||
verify(mailSender).send(mimeMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendSigningInvitationEmail_doesNotThrowForHtmlInMessage() throws MessagingException {
|
||||
when(applicationProperties.getMail()).thenReturn(mailProperties);
|
||||
when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com");
|
||||
MimeMessage mimeMessage = mock(MimeMessage.class);
|
||||
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||
|
||||
// Personal message with HTML injection
|
||||
emailService.sendSigningInvitationEmail(
|
||||
"guest@example.com",
|
||||
"Bob <b>Owner</b>",
|
||||
"Contract.pdf",
|
||||
"https://example.com/sign/tok",
|
||||
"2026-12-31",
|
||||
"<img src=x onerror=alert(1)> Please sign");
|
||||
|
||||
verify(mailSender).send(mimeMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendSigningInvitationEmail_doesNotThrowForJavascriptUrl() throws MessagingException {
|
||||
when(applicationProperties.getMail()).thenReturn(mailProperties);
|
||||
when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com");
|
||||
MimeMessage mimeMessage = mock(MimeMessage.class);
|
||||
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||
|
||||
// javascript: URL — must be replaced with '#', not thrown into email
|
||||
emailService.sendSigningInvitationEmail(
|
||||
"guest@example.com",
|
||||
"Owner",
|
||||
"Contract.pdf",
|
||||
"javascript:alert(document.cookie)",
|
||||
null,
|
||||
null);
|
||||
|
||||
verify(mailSender).send(mimeMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendSigningInvitationEmail_acceptsNullOptionalParams() throws MessagingException {
|
||||
when(applicationProperties.getMail()).thenReturn(mailProperties);
|
||||
when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com");
|
||||
MimeMessage mimeMessage = mock(MimeMessage.class);
|
||||
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||
|
||||
// expiresAt and message are optional — null values must not cause NPE
|
||||
emailService.sendSigningInvitationEmail(
|
||||
"guest@example.com", null, null, "https://example.com/sign/tok", null, null);
|
||||
|
||||
verify(mailSender).send(mimeMessage);
|
||||
}
|
||||
}
|
||||
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package stirling.software.proprietary.workflow.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
|
||||
import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest;
|
||||
import stirling.software.proprietary.workflow.model.ParticipantStatus;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WorkflowParticipantControllerTest {
|
||||
|
||||
@Mock private WorkflowSessionService workflowSessionService;
|
||||
@Mock private WorkflowParticipantRepository participantRepository;
|
||||
@Mock private MetadataEncryptionService metadataEncryptionService;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
private WorkflowParticipantController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
controller =
|
||||
new WorkflowParticipantController(
|
||||
workflowSessionService,
|
||||
participantRepository,
|
||||
objectMapper,
|
||||
metadataEncryptionService);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private WorkflowParticipant guestParticipant(String token, String email) {
|
||||
WorkflowSession session = new WorkflowSession();
|
||||
session.setStatus(WorkflowStatus.IN_PROGRESS);
|
||||
session.setFinalized(false);
|
||||
|
||||
WorkflowParticipant participant = new WorkflowParticipant();
|
||||
participant.setId(1L);
|
||||
participant.setShareToken(token);
|
||||
participant.setEmail(email);
|
||||
participant.setUser(null); // guest — no registered user
|
||||
participant.setStatus(ParticipantStatus.VIEWED);
|
||||
participant.setWorkflowSession(session);
|
||||
return participant;
|
||||
}
|
||||
|
||||
// ── GUEST_CERT defaulting ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void submitSignature_guestNoCertType_defaultsToGuestCert() throws Exception {
|
||||
String token = "test-token-123";
|
||||
WorkflowParticipant participant = guestParticipant(token, "guest@example.com");
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
when(participantRepository.save(any())).thenReturn(participant);
|
||||
// No encrypt() stub — GUEST_CERT skips encryption entirely (fix verified in separate test)
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
// certType intentionally not set — should default to GUEST_CERT
|
||||
|
||||
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
|
||||
httpRequest.setRemoteAddr("192.168.1.1");
|
||||
httpRequest.addHeader("User-Agent", "TestBrowser/1.0");
|
||||
|
||||
ResponseEntity<ParticipantResponse> response =
|
||||
controller.submitSignature(request, httpRequest);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// Capture the saved participant and verify certType in metadata
|
||||
ArgumentCaptor<WorkflowParticipant> captor =
|
||||
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||
verify(participantRepository).save(captor.capture());
|
||||
|
||||
WorkflowParticipant saved = captor.getValue();
|
||||
assertThat(saved.getStatus()).isEqualTo(ParticipantStatus.SIGNED);
|
||||
|
||||
Map<String, Object> metadata = saved.getParticipantMetadata();
|
||||
assertThat(metadata).containsKey("certificateSubmission");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> certSub = (Map<String, Object>) metadata.get("certificateSubmission");
|
||||
assertThat(certSub.get("certType")).isEqualTo("GUEST_CERT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitSignature_registeredUserNoCertType_doesNotDefaultToGuestCert() throws Exception {
|
||||
String token = "registered-token";
|
||||
WorkflowParticipant participant = guestParticipant(token, "user@example.com");
|
||||
|
||||
// Give participant a registered user
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("registered");
|
||||
participant.setUser(user);
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
when(participantRepository.save(any())).thenReturn(participant);
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
// certType intentionally not set
|
||||
|
||||
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
|
||||
|
||||
ResponseEntity<ParticipantResponse> response =
|
||||
controller.submitSignature(request, httpRequest);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
ArgumentCaptor<WorkflowParticipant> captor =
|
||||
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||
verify(participantRepository).save(captor.capture());
|
||||
|
||||
WorkflowParticipant saved = captor.getValue();
|
||||
Map<String, Object> metadata = saved.getParticipantMetadata();
|
||||
// No certType defaulted — certificateSubmission should be absent
|
||||
assertThat(metadata).doesNotContainKey("certificateSubmission");
|
||||
}
|
||||
|
||||
// ── Audit trail ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void submitSignature_capturesAuditTrail() throws Exception {
|
||||
String token = "audit-token";
|
||||
WorkflowParticipant participant = guestParticipant(token, "audited@example.com");
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
when(participantRepository.save(any())).thenReturn(participant);
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
|
||||
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
|
||||
httpRequest.setRemoteAddr("10.0.0.1");
|
||||
httpRequest.addHeader("User-Agent", "AuditBrowser/2.0");
|
||||
|
||||
controller.submitSignature(request, httpRequest);
|
||||
|
||||
ArgumentCaptor<WorkflowParticipant> captor =
|
||||
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||
verify(participantRepository).save(captor.capture());
|
||||
|
||||
Map<String, Object> metadata = captor.getValue().getParticipantMetadata();
|
||||
assertThat(metadata).containsKey("auditTrail");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> audit = (Map<String, Object>) metadata.get("auditTrail");
|
||||
assertThat(audit).containsKey("ipHash");
|
||||
assertThat(audit).containsKey("userAgent");
|
||||
assertThat(audit).containsKey("submittedAt");
|
||||
assertThat(audit.get("email")).isEqualTo("audited@example.com");
|
||||
// IP should be hashed, not stored raw
|
||||
assertThat(audit.get("ipHash")).isNotEqualTo("10.0.0.1");
|
||||
assertThat(audit.get("userAgent")).isEqualTo("AuditBrowser/2.0");
|
||||
}
|
||||
|
||||
// ── GUEST_CERT password not stored ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void submitSignature_guestCert_doesNotStoreEncryptedPassword() throws Exception {
|
||||
String token = "no-pwd-token";
|
||||
WorkflowParticipant participant = guestParticipant(token, "guest@example.com");
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
when(participantRepository.save(any())).thenReturn(participant);
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
// certType not set → defaults to GUEST_CERT; no password submitted
|
||||
|
||||
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
|
||||
|
||||
controller.submitSignature(request, httpRequest);
|
||||
|
||||
// metadataEncryptionService.encrypt() must NOT be called for GUEST_CERT
|
||||
verify(metadataEncryptionService, never()).encrypt(any());
|
||||
|
||||
ArgumentCaptor<WorkflowParticipant> captor =
|
||||
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||
verify(participantRepository).save(captor.capture());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> certSub =
|
||||
(Map<String, Object>)
|
||||
captor.getValue().getParticipantMetadata().get("certificateSubmission");
|
||||
assertThat(certSub.get("password")).isNull();
|
||||
}
|
||||
|
||||
// ── Audit trail edge cases ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void submitSignature_userAgentTruncatedAt500Chars() throws Exception {
|
||||
String token = "ua-token";
|
||||
WorkflowParticipant participant = guestParticipant(token, "ua@example.com");
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
when(participantRepository.save(any())).thenReturn(participant);
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
|
||||
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
|
||||
httpRequest.addHeader("User-Agent", "A".repeat(600));
|
||||
|
||||
controller.submitSignature(request, httpRequest);
|
||||
|
||||
ArgumentCaptor<WorkflowParticipant> captor =
|
||||
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||
verify(participantRepository).save(captor.capture());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> audit =
|
||||
(Map<String, Object>) captor.getValue().getParticipantMetadata().get("auditTrail");
|
||||
String storedUa = (String) audit.get("userAgent");
|
||||
assertThat(storedUa).hasSize(500);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitSignature_nullIp_storesNullHash() throws Exception {
|
||||
String token = "null-ip-token";
|
||||
WorkflowParticipant participant = guestParticipant(token, "noip@example.com");
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
when(participantRepository.save(any())).thenReturn(participant);
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
|
||||
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
|
||||
httpRequest.setRemoteAddr(null);
|
||||
|
||||
controller.submitSignature(request, httpRequest);
|
||||
|
||||
ArgumentCaptor<WorkflowParticipant> captor =
|
||||
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||
verify(participantRepository).save(captor.capture());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> audit =
|
||||
(Map<String, Object>) captor.getValue().getParticipantMetadata().get("auditTrail");
|
||||
assertThat(audit.get("ipHash")).isNull();
|
||||
}
|
||||
|
||||
// ── Token guards ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void submitSignature_expiredToken_returnsForbidden() {
|
||||
String token = "expired-token";
|
||||
WorkflowParticipant participant = guestParticipant(token, "expired@example.com");
|
||||
participant.setExpiresAt(java.time.LocalDateTime.now().minusDays(1)); // expired
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
|
||||
org.springframework.web.server.ResponseStatusException ex =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> controller.submitSignature(request, new MockHttpServletRequest()));
|
||||
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitSignature_alreadySigned_returnsBadRequest() {
|
||||
String token = "signed-token";
|
||||
WorkflowParticipant participant = guestParticipant(token, "done@example.com");
|
||||
participant.setStatus(ParticipantStatus.SIGNED);
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
|
||||
org.springframework.web.server.ResponseStatusException ex =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> controller.submitSignature(request, new MockHttpServletRequest()));
|
||||
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitSignature_alreadyDeclined_returnsBadRequest() {
|
||||
String token = "declined-token";
|
||||
WorkflowParticipant participant = guestParticipant(token, "declined@example.com");
|
||||
participant.setStatus(ParticipantStatus.DECLINED);
|
||||
|
||||
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(token);
|
||||
|
||||
org.springframework.web.server.ResponseStatusException ex =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> controller.submitSignature(request, new MockHttpServletRequest()));
|
||||
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitSignature_unknownToken_returnsForbidden() {
|
||||
when(participantRepository.findByShareToken("no-such-token")).thenReturn(Optional.empty());
|
||||
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken("no-such-token");
|
||||
|
||||
org.springframework.web.server.ResponseStatusException ex =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> controller.submitSignature(request, new MockHttpServletRequest()));
|
||||
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitSignature_blankToken_returnsBadRequest() {
|
||||
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
|
||||
request.setParticipantToken(" ");
|
||||
|
||||
org.springframework.web.server.ResponseStatusException ex =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> controller.submitSignature(request, new MockHttpServletRequest()));
|
||||
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package stirling.software.proprietary.workflow.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GuestCertificateServiceTest {
|
||||
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
|
||||
@Mock private AutomaticallyGenerated automaticallyGenerated;
|
||||
|
||||
private GuestCertificateService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
|
||||
when(automaticallyGenerated.getKey()).thenReturn("test-secret-key-for-unit-tests");
|
||||
service = new GuestCertificateService(applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateGuestKeyStore_createsValidPKCS12() throws Exception {
|
||||
String email = "signer@example.com";
|
||||
|
||||
KeyStore keyStore = service.generateGuestKeyStore(email);
|
||||
|
||||
assertThat(keyStore).isNotNull();
|
||||
assertThat(keyStore.getType()).isEqualToIgnoringCase("PKCS12");
|
||||
assertThat(keyStore.aliases().hasMoreElements()).isTrue();
|
||||
String alias = keyStore.aliases().nextElement();
|
||||
assertThat(keyStore.isKeyEntry(alias)).isTrue();
|
||||
assertThat(keyStore.getCertificate(alias)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateGuestKeyStore_subjectContainsSanitizedEmail() throws Exception {
|
||||
String email = "signer@example.com";
|
||||
|
||||
KeyStore keyStore = service.generateGuestKeyStore(email);
|
||||
String alias = keyStore.aliases().nextElement();
|
||||
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
|
||||
|
||||
String subjectDn = cert.getSubjectX500Principal().getName();
|
||||
assertThat(subjectDn).contains("signer@example.com");
|
||||
assertThat(subjectDn).contains("Stirling-PDF Guest");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateGuestKeyStore_sanContainsEmailRfc822Name() throws Exception {
|
||||
String email = "guest@test.org";
|
||||
|
||||
KeyStore keyStore = service.generateGuestKeyStore(email);
|
||||
String alias = keyStore.aliases().nextElement();
|
||||
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
|
||||
|
||||
// Parse SAN extension via BouncyCastle
|
||||
JcaX509CertificateHolder holder = new JcaX509CertificateHolder(cert);
|
||||
org.bouncycastle.asn1.x509.GeneralNames sans =
|
||||
org.bouncycastle.asn1.x509.GeneralNames.getInstance(
|
||||
holder.getExtension(Extension.subjectAlternativeName).getParsedValue());
|
||||
|
||||
boolean foundEmail = false;
|
||||
for (org.bouncycastle.asn1.x509.GeneralName gn : sans.getNames()) {
|
||||
if (gn.getTagNo() == org.bouncycastle.asn1.x509.GeneralName.rfc822Name) {
|
||||
String value = gn.getName().toString();
|
||||
if (value.equals(email)) {
|
||||
foundEmail = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(foundEmail)
|
||||
.as("SAN should contain rfc822Name matching the signer's email")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateGuestPassword_isDeterministic() {
|
||||
String email = "signer@example.com";
|
||||
|
||||
String pw1 = service.generateGuestPassword(email);
|
||||
String pw2 = service.generateGuestPassword(email);
|
||||
|
||||
assertThat(pw1).isEqualTo(pw2);
|
||||
assertThat(pw1).hasSize(32);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateGuestPassword_differentEmails_differentPasswords() {
|
||||
String pw1 = service.generateGuestPassword("alice@example.com");
|
||||
String pw2 = service.generateGuestPassword("bob@example.com");
|
||||
|
||||
assertThat(pw1).isNotEqualTo(pw2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateGuestPassword_fallbackWhenNoKey() {
|
||||
// If no app key is configured, should fall back to SHA-256 of email
|
||||
when(automaticallyGenerated.getKey()).thenReturn(null);
|
||||
|
||||
String pw = service.generateGuestPassword("fallback@example.com");
|
||||
|
||||
assertThat(pw).isNotNull();
|
||||
assertThat(pw).hasSize(32);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateGuestKeyStore_certIsValidForSigning() throws Exception {
|
||||
String email = "signer@example.com";
|
||||
|
||||
KeyStore keyStore = service.generateGuestKeyStore(email);
|
||||
String alias = keyStore.aliases().nextElement();
|
||||
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
|
||||
|
||||
// Certificate must be currently valid
|
||||
cert.checkValidity();
|
||||
|
||||
// Key usage must include digitalSignature (bit 0) and nonRepudiation (bit 1)
|
||||
boolean[] keyUsage = cert.getKeyUsage();
|
||||
assertThat(keyUsage).isNotNull();
|
||||
assertThat(keyUsage[0]).as("digitalSignature key usage").isTrue();
|
||||
assertThat(keyUsage[1]).as("nonRepudiation key usage").isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
# Guest Signing — Test Plan
|
||||
|
||||
## Automated coverage summary
|
||||
|
||||
The following scenarios are already covered by automated tests and **do not need manual testing**:
|
||||
|
||||
| Area | Coverage | Test file |
|
||||
|------|----------|-----------|
|
||||
| Guest certificate generation (PKCS12, SAN, key usage) | Unit | `GuestCertificateServiceTest.java` |
|
||||
| EKU is `emailProtection` (not `codeSigning`) | Unit | `GuestCertificateServiceTest.java` |
|
||||
| Password determinism / uniqueness | Unit | `GuestCertificateServiceTest.java` |
|
||||
| GUEST_CERT defaulted for external participants | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Registered users not defaulted to GUEST_CERT | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Audit trail: IP hashed, UA stored, timestamp present | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Audit trail: User-agent truncated at 500 chars | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Audit trail: null IP stored as null (not exception) | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| GUEST_CERT password not stored in metadata | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Expired token → 403 | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Already-SIGNED → 400 | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Already-DECLINED → 400 | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Unknown token → 403 | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| Blank token → 400 | Unit | `WorkflowParticipantControllerTest.java` |
|
||||
| HTML injection in email: docName, ownerName, message | Unit | `EmailServiceTest.java` |
|
||||
| `javascript:` URL replaced with `#` in email | Unit | `EmailServiceTest.java` |
|
||||
| Email sends for null optional params | Unit | `EmailServiceTest.java` |
|
||||
| `/sign/:token` — loading spinner shown | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| `/sign/:token` — expired (403) page | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| `/sign/:token` — already-SIGNED state on load | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| `/sign/:token` — already-DECLINED state on load | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| `/sign/:token` — 500 error page | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Signing form renders with doc name, owner, message | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| PDF iframe present with correct src | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Auto-cert selected by default + info alert | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Switching to P12 shows file + password inputs | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Switching back to auto-cert hides P12 inputs | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Submit → success page; FormData contains GUEST_CERT + token | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Submit failure shows error with server message | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Submit button disabled while in-flight | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Decline → opens modal with correct title + body | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Decline → Cancel closes modal, form still visible | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| Decline → Confirm transitions to declined state | E2E | `GuestSigningE2E.spec.ts` |
|
||||
| SelectParticipantsStep — registered/external tabs | Unit | `SelectParticipantsStep.test.tsx` |
|
||||
| SelectParticipantsStep — email validation + duplicates | Unit | `SelectParticipantsStep.test.tsx` |
|
||||
| SelectParticipantsStep — add by button / Enter key | Unit | `SelectParticipantsStep.test.tsx` |
|
||||
| SelectParticipantsStep — remove participant | Unit | `SelectParticipantsStep.test.tsx` |
|
||||
| SelectParticipantsStep — Continue disabled when empty | Unit | `SelectParticipantsStep.test.tsx` |
|
||||
| GuestSignPage — all page states (loading/expired/signed/etc.) | Unit | `GuestSignPage.test.tsx` |
|
||||
| GuestSignPage — submit calls correct endpoint | Unit | `GuestSignPage.test.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## Running the automated tests
|
||||
|
||||
```bash
|
||||
# Backend unit tests (from repo root)
|
||||
./gradlew :app:proprietary:test \
|
||||
--tests "stirling.software.proprietary.workflow.service.GuestCertificateServiceTest" \
|
||||
--tests "stirling.software.proprietary.workflow.controller.WorkflowParticipantControllerTest" \
|
||||
--tests "stirling.software.proprietary.security.service.EmailServiceTest"
|
||||
|
||||
# Frontend unit tests
|
||||
cd frontend && npm test -- --run \
|
||||
src/core/routes/GuestSignPage.test.tsx \
|
||||
src/core/components/shared/signing/steps/SelectParticipantsStep.test.tsx
|
||||
|
||||
# Frontend E2E (requires dev server running)
|
||||
cd frontend && npx playwright test src/core/tests/guestSigning/GuestSigningE2E.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Residual manual test plan
|
||||
|
||||
These scenarios require a running system with real SMTP, storage, and a PDF.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Stirling PDF running with the config below applied
|
||||
- A real SMTP server (or MailHog locally) receiving email
|
||||
- A test PDF document
|
||||
- A valid .p12 certificate for the P12 upload test
|
||||
|
||||
---
|
||||
|
||||
### MT-1 — Owner invites an external guest (email delivery)
|
||||
|
||||
**Steps:**
|
||||
1. Log in as a registered user
|
||||
2. Open a PDF and start a signing session
|
||||
3. In the participants step, switch to the **External (by email)** tab
|
||||
4. Enter a real email address and click Add
|
||||
5. Continue through the session creation flow
|
||||
6. Check the inbox of the guest email address
|
||||
|
||||
**Expected:**
|
||||
- Email received with subject `Please sign: <document name>`
|
||||
- Email shows owner's email, document name, optional message
|
||||
- "Review and Sign Document" button links to `https://<your-host>/sign/<token>`
|
||||
- No raw HTML visible in the email body (escaping check)
|
||||
- No raw IP address visible anywhere in the email
|
||||
|
||||
---
|
||||
|
||||
### MT-2 — Guest signs with auto-generated certificate (happy path)
|
||||
|
||||
**Steps:**
|
||||
1. Open the signing link from MT-1
|
||||
2. Verify the loading spinner appears briefly
|
||||
3. Verify document name and owner info are displayed
|
||||
4. Verify PDF is visible in the embedded preview
|
||||
5. Verify **"Use auto-generated certificate (recommended)"** is selected by default
|
||||
6. Draw a signature in the canvas
|
||||
7. Click **Submit Signature**
|
||||
|
||||
**Expected:**
|
||||
- Success page: "Your signature has been submitted successfully."
|
||||
- PDF in session now has a digital signature
|
||||
- Signature certificate CN includes the guest's email (sanitized)
|
||||
- Certificate SAN contains `rfc822Name=<email>` — verify in Adobe Acrobat / PDF viewer signature panel
|
||||
- Participant status in admin panel changed to SIGNED
|
||||
- Audit trail in database: `ipHash` is a Base64 SHA-256 (not raw IP), `userAgent` matches browser, `submittedAt` timestamp is recent
|
||||
|
||||
---
|
||||
|
||||
### MT-3 — Guest signs with their own P12 certificate
|
||||
|
||||
**Steps:**
|
||||
1. Open a fresh signing link (new session)
|
||||
2. Select **"Upload my own certificate"**
|
||||
3. Upload a valid `.p12` file
|
||||
4. Enter the certificate password
|
||||
5. Draw a signature and submit
|
||||
|
||||
**Expected:**
|
||||
- Success page shown
|
||||
- PDF signed with the uploaded certificate (not a Stirling-generated one)
|
||||
- Signature visible in PDF viewer with details from the P12
|
||||
|
||||
---
|
||||
|
||||
### MT-4 — Guest uses an expired signing link
|
||||
|
||||
**Steps:**
|
||||
1. Set a participant's `expires_at` to the past in the database (or wait for expiry)
|
||||
2. Navigate to the signing link
|
||||
|
||||
**Expected:**
|
||||
- "This signing link has expired." page
|
||||
- Contact message displayed
|
||||
- No way to proceed to the signing form
|
||||
|
||||
---
|
||||
|
||||
### MT-5 — Guest declines and owner is notified
|
||||
|
||||
**Steps:**
|
||||
1. Open a valid signing link
|
||||
2. Click **Decline**
|
||||
3. Confirm in the modal
|
||||
|
||||
**Expected:**
|
||||
- "You have declined this signing request." page
|
||||
- Participant status in session changes to DECLINED
|
||||
- Session owner sees participant marked as declined in the session view
|
||||
|
||||
---
|
||||
|
||||
### MT-6 — Duplicate submission prevented
|
||||
|
||||
**Steps:**
|
||||
1. Complete signing (MT-2)
|
||||
2. Navigate back to the same `/sign/:token` URL
|
||||
|
||||
**Expected:**
|
||||
- Page immediately shows the already-signed state ("Your signature has been submitted successfully.")
|
||||
- No signing form is shown
|
||||
|
||||
---
|
||||
|
||||
### MT-7 — Email content security
|
||||
|
||||
**Steps:**
|
||||
1. Create a session with a document named: `<img src=x onerror=alert(1)>.pdf`
|
||||
2. Send invitation to a guest
|
||||
|
||||
**Expected:**
|
||||
- Email subject: `Please sign: <img src=x onerror=alert(1)>.pdf` (angle brackets visible as text, not rendered)
|
||||
- Email body: document name appears as text `<img src=x onerror=alert(1)>.pdf`
|
||||
- No JavaScript executes in the email client
|
||||
|
||||
---
|
||||
|
||||
### MT-8 — Mobile / responsive layout
|
||||
|
||||
**Steps:**
|
||||
1. Open the signing link on a mobile device or via browser DevTools responsive mode (375px width)
|
||||
|
||||
**Expected:**
|
||||
- Page is usable at mobile width
|
||||
- PDF preview, certificate chooser, and signature canvas are all visible and usable
|
||||
- Buttons are appropriately sized for touch
|
||||
|
||||
---
|
||||
|
||||
### MT-9 — No-email configuration (mail disabled)
|
||||
|
||||
**Steps:**
|
||||
1. Disable `mail.enabled` in config
|
||||
2. Create a session with an external guest participant
|
||||
|
||||
**Expected:**
|
||||
- Session creation succeeds
|
||||
- No email is sent (no error in logs)
|
||||
- Participant status remains `PENDING` (not `NOTIFIED`)
|
||||
- Operator can still manually share the signing URL from the session detail view
|
||||
|
||||
---
|
||||
|
||||
## Config required
|
||||
|
||||
Add to your `settings.yml` (or environment variables):
|
||||
|
||||
```yaml
|
||||
storage:
|
||||
enabled: true # required for group signing
|
||||
signing:
|
||||
enabled: true # enables the signing session feature
|
||||
|
||||
mail:
|
||||
enabled: true
|
||||
host: smtp.example.com # your SMTP server
|
||||
port: 587
|
||||
username: stirling@example.com
|
||||
password: your-smtp-password
|
||||
from: noreply@stirling-pdf.example.com
|
||||
startTlsEnable: true # recommended; use sslEnable: true + port 465 for implicit TLS
|
||||
```
|
||||
|
||||
### Local development with MailHog
|
||||
|
||||
MailHog provides a local SMTP server with a web UI at `http://localhost:8025`.
|
||||
|
||||
```bash
|
||||
# Start MailHog (Docker)
|
||||
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
|
||||
|
||||
# settings.yml for local dev
|
||||
mail:
|
||||
enabled: true
|
||||
host: localhost
|
||||
port: 1025
|
||||
username: ""
|
||||
password: ""
|
||||
from: test@stirling-pdf.local
|
||||
startTlsEnable: false
|
||||
```
|
||||
|
||||
### Environment variable equivalents
|
||||
|
||||
```
|
||||
STIRLING_STORAGE_ENABLED=true
|
||||
STIRLING_STORAGE_SIGNING_ENABLED=true
|
||||
MAIL_ENABLED=true
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=stirling@example.com
|
||||
MAIL_PASSWORD=secret
|
||||
MAIL_FROM=noreply@stirling-pdf.example.com
|
||||
MAIL_STARTTLS_ENABLE=true
|
||||
```
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:5173',
|
||||
baseURL: 'http://localhost:5174',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
@@ -68,8 +68,8 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
command: 'npm run dev -- --port 5174',
|
||||
url: 'http://localhost:5174',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@@ -3750,10 +3750,20 @@ selectedFile = "Selected document"
|
||||
title = "Select Document"
|
||||
|
||||
[groupSigning.steps.selectParticipants]
|
||||
addEmail = "Add"
|
||||
badgeExternal = "Guest"
|
||||
badgeRegistered = "User"
|
||||
continue = "Continue to Signature Settings"
|
||||
count = "{{count}} participant(s) selected"
|
||||
count = "{{count}} participant(s)"
|
||||
duplicateEmail = "This email has already been added"
|
||||
emailInviteNote = "External participants will receive an email invitation with a signing link."
|
||||
emailPlaceholder = "signer@example.com"
|
||||
externalNote = "An invitation email will be sent with a signing link. No account required."
|
||||
invalidEmail = "Please enter a valid email address"
|
||||
label = "Select participants"
|
||||
placeholder = "Choose participants to sign..."
|
||||
tabExternal = "External (by email)"
|
||||
tabRegistered = "Registered Users"
|
||||
title = "Choose Participants"
|
||||
|
||||
[getPdfInfo]
|
||||
@@ -3868,6 +3878,35 @@ message = "Create a free account to save your work, access more features, and su
|
||||
signUp = "Sign Up Free"
|
||||
title = "You're using Stirling PDF as a guest!"
|
||||
|
||||
[guestSigning]
|
||||
certAutoNote = "A certificate will be generated using your email address for traceability."
|
||||
certChoiceAuto = "Use auto-generated certificate (recommended)"
|
||||
certChoiceUpload = "Upload my own certificate"
|
||||
certFileLabel = "Certificate file (.p12 / .pfx)"
|
||||
certFilePlaceholder = "Select .p12 or .pfx file"
|
||||
certFileRequired = "Please select a certificate file."
|
||||
certPasswordLabel = "Certificate password"
|
||||
certPasswordPlaceholder = "Enter certificate password"
|
||||
certSectionTitle = "Signing Certificate"
|
||||
declineButton = "Decline"
|
||||
declineConfirmBody = "Are you sure you want to decline? This action cannot be undone."
|
||||
declineConfirmTitle = "Decline signing?"
|
||||
declinedNote = "The document owner has been notified. You may close this window."
|
||||
declineSuccess = "You have declined this signing request."
|
||||
documentPreview = "Document"
|
||||
dueDate = "Due {{date}}"
|
||||
errorTitle = "Something went wrong"
|
||||
expiredNote = "Please contact the document owner for a new link."
|
||||
expiredToken = "This signing link has expired."
|
||||
loadingSession = "Loading signing session..."
|
||||
pageTitle = "Sign Document"
|
||||
requestedBy = "Requested by {{owner}}"
|
||||
signatureTitle = "Your Signature"
|
||||
signedNote = "The document owner has been notified. You may close this window."
|
||||
submitButton = "Submit Signature"
|
||||
submitError = "Failed to submit signature."
|
||||
submitSuccess = "Your signature has been submitted successfully."
|
||||
|
||||
[home]
|
||||
alphabetical = "Alphabetical"
|
||||
desc = "Your locally hosted one-stop-shop for all your PDF needs."
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Stack, Text, Group, Badge } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { SelectDocumentStep } from '@app/components/shared/signing/steps/SelectDocumentStep';
|
||||
import { SelectParticipantsStep } from '@app/components/shared/signing/steps/SelectParticipantsStep';
|
||||
import {
|
||||
SelectParticipantsStep,
|
||||
type Participant,
|
||||
} from '@app/components/shared/signing/steps/SelectParticipantsStep';
|
||||
import { ConfigureSignatureDefaultsStep } from '@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep';
|
||||
import { ReviewSessionStep } from '@app/components/shared/signing/steps/ReviewSessionStep';
|
||||
import { useGroupSigningTips } from '@app/components/tooltips/useGroupSigningTips';
|
||||
@@ -13,8 +16,8 @@ import type { FileState } from '@app/types/file';
|
||||
|
||||
interface CreateSessionFlowProps {
|
||||
selectedFiles: FileState[];
|
||||
selectedUserIds: number[];
|
||||
onSelectedUserIdsChange: (userIds: number[]) => void;
|
||||
participants: Participant[];
|
||||
onParticipantsChange: (participants: Participant[]) => void;
|
||||
dueDate: string;
|
||||
onDueDateChange: (date: string) => void;
|
||||
creating: boolean;
|
||||
@@ -102,8 +105,8 @@ const StepWrapper: React.FC<StepWrapperProps> = ({
|
||||
|
||||
export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
|
||||
selectedFiles,
|
||||
selectedUserIds,
|
||||
onSelectedUserIdsChange,
|
||||
participants,
|
||||
onParticipantsChange,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
creating,
|
||||
@@ -186,8 +189,8 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
|
||||
isCompleted={currentStep > 2}
|
||||
>
|
||||
<SelectParticipantsStep
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectedUserIdsChange={onSelectedUserIdsChange}
|
||||
participants={participants}
|
||||
onParticipantsChange={onParticipantsChange}
|
||||
onBack={() => setCurrentStep(1)}
|
||||
onNext={() => setCurrentStep(3)}
|
||||
disabled={creating}
|
||||
@@ -220,7 +223,7 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
|
||||
{selectedFile && (
|
||||
<ReviewSessionStep
|
||||
selectedFile={selectedFile}
|
||||
participantCount={selectedUserIds.length}
|
||||
participantCount={participants.length}
|
||||
signatureSettings={signatureSettings}
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={onDueDateChange}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, Switch } from '@mantine/core';
|
||||
import { ActionIcon, Badge, Group, Switch, Tabs, Text, TextInput } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import UserSelector from '@app/components/shared/UserSelector';
|
||||
import type { Participant } from '@app/components/shared/signing/steps/SelectParticipantsStep';
|
||||
import type { FileState } from '@app/types/file';
|
||||
|
||||
interface CreateSessionPanelProps {
|
||||
selectedFiles: FileState[];
|
||||
selectedUserIds: number[];
|
||||
onSelectedUserIdsChange: (userIds: number[]) => void;
|
||||
participants: Participant[];
|
||||
onParticipantsChange: (participants: Participant[]) => void;
|
||||
dueDate: string;
|
||||
onDueDateChange: (date: string) => void;
|
||||
creating: boolean;
|
||||
@@ -14,10 +19,12 @@ interface CreateSessionPanelProps {
|
||||
onIncludeSummaryPageChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const CreateSessionPanel = ({
|
||||
selectedFiles,
|
||||
selectedUserIds,
|
||||
onSelectedUserIdsChange,
|
||||
participants,
|
||||
onParticipantsChange,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
creating,
|
||||
@@ -25,9 +32,40 @@ const CreateSessionPanel = ({
|
||||
onIncludeSummaryPageChange,
|
||||
}: CreateSessionPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [emailInput, setEmailInput] = useState('');
|
||||
|
||||
const hasValidFile = selectedFiles.length === 1;
|
||||
|
||||
const registeredUserIds = participants
|
||||
.filter((p) => p.type === 'registered' && p.userId != null)
|
||||
.map((p) => p.userId as number);
|
||||
|
||||
function handleRegisteredChange(userIds: number[]) {
|
||||
const external = participants.filter((p) => p.type === 'external');
|
||||
onParticipantsChange([
|
||||
...userIds.map((id): Participant => ({ type: 'registered', userId: id })),
|
||||
...external,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleAddEmail() {
|
||||
const trimmed = emailInput.trim();
|
||||
if (!EMAIL_RE.test(trimmed)) return;
|
||||
if (participants.some((p) => p.email === trimmed)) return;
|
||||
onParticipantsChange([...participants, { type: 'external', email: trimmed }]);
|
||||
setEmailInput('');
|
||||
}
|
||||
|
||||
function handleRemove(participant: Participant) {
|
||||
onParticipantsChange(
|
||||
participants.filter((p) =>
|
||||
participant.type === 'registered'
|
||||
? !(p.type === 'registered' && p.userId === participant.userId)
|
||||
: !(p.type === 'external' && p.email === participant.email)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="quick-access-popout__panel">
|
||||
{!hasValidFile ? (
|
||||
@@ -46,14 +84,89 @@ const CreateSessionPanel = ({
|
||||
</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 className="quick-access-popout__label">{t('quickAccess.selectUsers', 'Select participants to sign')}</div>
|
||||
|
||||
<Tabs defaultValue="registered" style={{ marginBottom: 8 }}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="registered" leftSection={<PersonIcon sx={{ fontSize: 13 }} />} style={{ fontSize: 11, padding: '4px 8px' }}>
|
||||
{t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="external" leftSection={<EmailIcon sx={{ fontSize: 13 }} />} style={{ fontSize: 11, padding: '4px 8px' }}>
|
||||
{t('groupSigning.steps.selectParticipants.tabExternal', 'External')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="registered" pt="xs">
|
||||
<UserSelector
|
||||
value={registeredUserIds}
|
||||
onChange={handleRegisteredChange}
|
||||
size="xs"
|
||||
placeholder={t('quickAccess.selectUsersPlaceholder', 'Choose participants...')}
|
||||
disabled={creating}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="external" pt="xs">
|
||||
<Group gap="xs" align="center">
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
placeholder="signer@example.com"
|
||||
value={emailInput}
|
||||
onChange={(e) => setEmailInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddEmail();
|
||||
}
|
||||
}}
|
||||
disabled={creating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__secondary"
|
||||
onClick={handleAddEmail}
|
||||
disabled={creating || !EMAIL_RE.test(emailInput.trim())}
|
||||
style={{ padding: '4px 8px', fontSize: 12 }}
|
||||
>
|
||||
{t('groupSigning.steps.selectParticipants.addEmail', 'Add')}
|
||||
</button>
|
||||
</Group>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Combined participant list */}
|
||||
{participants.length > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{participants.map((p, i) => (
|
||||
<Group key={i} gap={4} justify="space-between" wrap="nowrap" style={{ marginBottom: 2 }}>
|
||||
<Group gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={p.type === 'external' ? 'orange' : 'blue'}
|
||||
size="xs"
|
||||
>
|
||||
{p.type === 'external'
|
||||
? t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest')
|
||||
: t('groupSigning.steps.selectParticipants.badgeRegistered', 'User')}
|
||||
</Badge>
|
||||
<Text size="xs" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{p.type === 'external' ? p.email : `#${p.userId}`}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={() => handleRemove(p)}
|
||||
disabled={creating}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 12 }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="quick-access-popout__section">
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Alert, FileInput, PasswordInput, Radio, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type GuestCertType = 'GUEST_CERT' | 'P12';
|
||||
|
||||
interface GuestCertificateChooserProps {
|
||||
value: GuestCertType;
|
||||
onChange: (certType: GuestCertType) => void;
|
||||
onFileChange: (file: File | null) => void;
|
||||
onPasswordChange: (password: string) => void;
|
||||
p12File: File | null;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const GuestCertificateChooser: React.FC<GuestCertificateChooserProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onFileChange,
|
||||
onPasswordChange,
|
||||
p12File,
|
||||
password,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text fw={500} size="sm">
|
||||
{t('guestSigning.certSectionTitle', 'Signing Certificate')}
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val as GuestCertType);
|
||||
// Clear uploaded file when switching back to auto
|
||||
if (val === 'GUEST_CERT') {
|
||||
onFileChange(null);
|
||||
onPasswordChange('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Radio
|
||||
value="GUEST_CERT"
|
||||
label={t('guestSigning.certChoiceAuto', 'Use auto-generated certificate (recommended)')}
|
||||
/>
|
||||
<Radio
|
||||
value="P12"
|
||||
label={t('guestSigning.certChoiceUpload', 'Upload my own certificate')}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{value === 'GUEST_CERT' && (
|
||||
<Alert color="blue" variant="light">
|
||||
{t(
|
||||
'guestSigning.certAutoNote',
|
||||
'A certificate will be generated using your email address for traceability.'
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{value === 'P12' && (
|
||||
<Stack gap="xs">
|
||||
<FileInput
|
||||
label={t('guestSigning.certFileLabel', 'Certificate file (.p12 / .pfx)')}
|
||||
placeholder={t('guestSigning.certFilePlaceholder', 'Select .p12 or .pfx file')}
|
||||
accept=".p12,.pfx"
|
||||
value={p12File}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('guestSigning.certPasswordLabel', 'Certificate password')}
|
||||
placeholder={t('guestSigning.certPasswordPlaceholder', 'Enter certificate password')}
|
||||
value={password}
|
||||
onChange={(e) => onPasswordChange(e.currentTarget.value)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -82,7 +82,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Create form state
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [participants, setParticipants] = useState<import('@app/components/shared/signing/steps/SelectParticipantsStep').Participant[]>([]);
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [includeSummaryPage, setIncludeSummaryPage] = useState(false);
|
||||
@@ -309,7 +309,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
|
||||
// Create session handler
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
if (selectedUserIds.length === 0 || selectedFiles.length !== 1) return;
|
||||
if (participants.length === 0 || selectedFiles.length !== 1) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
@@ -321,8 +321,13 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
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());
|
||||
const registeredIds = participants.filter((p) => p.type === 'registered' && p.userId != null);
|
||||
const externalEmails = participants.filter((p) => p.type === 'external' && p.email != null);
|
||||
registeredIds.forEach((p, index) => {
|
||||
formData.append(`participantUserIds[${index}]`, (p.userId as number).toString());
|
||||
});
|
||||
externalEmails.forEach((p, index) => {
|
||||
formData.append(`participantEmails[${index}]`, p.email as string);
|
||||
});
|
||||
if (dueDate) formData.append('dueDate', dueDate);
|
||||
formData.append('notifyOnCreate', 'true');
|
||||
@@ -364,7 +369,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [selectedUserIds, dueDate, selectedFiles, fetchData, t, includeSummaryPage]);
|
||||
}, [participants, dueDate, selectedFiles, fetchData, t, includeSummaryPage]);
|
||||
|
||||
// Handle clicking a sign request
|
||||
const handleSignRequestClick = useCallback(async (request: SignRequestSummary) => {
|
||||
@@ -465,8 +470,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
pdfFile,
|
||||
onFinalize: () => handleFinalize(session.sessionId, session.documentName),
|
||||
onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName),
|
||||
onAddParticipants: (userIds: number[], defaultReason?: string) =>
|
||||
handleAddParticipants(session.sessionId, userIds, defaultReason),
|
||||
onAddParticipants: (userIds: number[], emails: string[], defaultReason?: string) =>
|
||||
handleAddParticipants(session.sessionId, userIds, emails, defaultReason),
|
||||
onRemoveParticipant: (participantId: number) => handleRemoveParticipant(session.sessionId, participantId),
|
||||
onDelete: () => handleDeleteSession(session.sessionId),
|
||||
onBack: () => {
|
||||
@@ -566,12 +571,19 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
navigationActions.setWorkbench('viewer');
|
||||
};
|
||||
|
||||
const handleAddParticipants = async (sessionId: string, userIds: number[], defaultReason?: string) => {
|
||||
const requests = userIds.map(userId => ({
|
||||
userId,
|
||||
defaultReason: defaultReason || undefined,
|
||||
sendNotification: true,
|
||||
}));
|
||||
const handleAddParticipants = async (sessionId: string, userIds: number[], emails: string[], defaultReason?: string) => {
|
||||
const requests = [
|
||||
...userIds.map(userId => ({
|
||||
userId,
|
||||
defaultReason: defaultReason || undefined,
|
||||
sendNotification: true,
|
||||
})),
|
||||
...emails.map(email => ({
|
||||
email,
|
||||
defaultReason: defaultReason || undefined,
|
||||
sendNotification: true,
|
||||
})),
|
||||
];
|
||||
await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/participants`, requests);
|
||||
await handleRefreshSession(sessionId);
|
||||
};
|
||||
@@ -750,8 +762,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
{showCreatePanel ? (
|
||||
<CreateSessionPanel
|
||||
selectedFiles={selectedFiles}
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectedUserIdsChange={setSelectedUserIds}
|
||||
participants={participants}
|
||||
onParticipantsChange={setParticipants}
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={setDueDate}
|
||||
creating={creating}
|
||||
@@ -793,7 +805,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
type="button"
|
||||
className="quick-access-popout__primary"
|
||||
onClick={handleCreateSession}
|
||||
disabled={selectedFiles.length !== 1 || selectedUserIds.length === 0 || creating}
|
||||
disabled={selectedFiles.length !== 1 || participants.length === 0 || creating}
|
||||
>
|
||||
<LocalIcon icon="send-rounded" width="1rem" height="1rem" />
|
||||
{creating
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
|
||||
// ── i18n ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') return fallback;
|
||||
if (typeof fallback === 'object' && fallback !== null) {
|
||||
// Handle interpolation like {{count}}
|
||||
const defaultValue = (fallback as Record<string, unknown>).defaultValue;
|
||||
if (typeof defaultValue === 'string') return defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
initReactI18next: { type: '3rdParty', init: vi.fn() },
|
||||
}));
|
||||
|
||||
// ── UserSelector stub — renders a simple multi-select ───────────────────────
|
||||
|
||||
vi.mock('@app/components/shared/UserSelector', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}) => (
|
||||
<div data-testid="user-selector">
|
||||
<button onClick={() => onChange([...value, 42])}>Add User 42</button>
|
||||
<button onClick={() => onChange([])}>Clear Users</button>
|
||||
<span data-testid="user-count">{value.length}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── MUI icons stub (to avoid SVG transform issues in jsdom) ─────────────────
|
||||
|
||||
vi.mock('@mui/icons-material/ArrowBack', () => ({ default: () => null }));
|
||||
vi.mock('@mui/icons-material/Close', () => ({ default: () => null }));
|
||||
vi.mock('@mui/icons-material/Person', () => ({ default: () => null }));
|
||||
vi.mock('@mui/icons-material/Email', () => ({ default: () => null }));
|
||||
|
||||
// ── Component under test ────────────────────────────────────────────────────
|
||||
|
||||
import { SelectParticipantsStep, Participant } from './SelectParticipantsStep';
|
||||
|
||||
// ── Test wrapper ────────────────────────────────────────────────────────────
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// ── Default props ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeProps(overrides?: Partial<{
|
||||
participants: Participant[];
|
||||
onParticipantsChange: (p: Participant[]) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}>) {
|
||||
return {
|
||||
participants: [] as Participant[],
|
||||
onParticipantsChange: vi.fn(),
|
||||
onBack: vi.fn(),
|
||||
onNext: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SelectParticipantsStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the registered users tab by default', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep {...makeProps()} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Registered Users')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('user-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the external tab', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep {...makeProps()} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('External (by email)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Continue button is disabled when participants list is empty', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep {...makeProps({ participants: [] })} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const continueBtn = screen.getByText('Continue to Signature Settings');
|
||||
expect(continueBtn.closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Continue button is enabled when participants list has entries', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep
|
||||
{...makeProps({
|
||||
participants: [{ type: 'external', email: 'alice@example.com' }],
|
||||
})}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const continueBtn = screen.getByText('Continue to Signature Settings');
|
||||
expect(continueBtn.closest('button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds external email to participant list on Add button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onParticipantsChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep
|
||||
{...makeProps({ onParticipantsChange })}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Switch to external tab
|
||||
await user.click(screen.getByText('External (by email)'));
|
||||
|
||||
const input = screen.getByPlaceholderText('signer@example.com');
|
||||
await user.type(input, 'new@example.com');
|
||||
await user.click(screen.getByText('Add'));
|
||||
|
||||
expect(onParticipantsChange).toHaveBeenCalledWith([
|
||||
{ type: 'external', email: 'new@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds external email on Enter keypress', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onParticipantsChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep {...makeProps({ onParticipantsChange })} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('External (by email)'));
|
||||
|
||||
const input = screen.getByPlaceholderText('signer@example.com');
|
||||
await user.type(input, 'enter@example.com{Enter}');
|
||||
|
||||
expect(onParticipantsChange).toHaveBeenCalledWith([
|
||||
{ type: 'external', email: 'enter@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows validation error for invalid email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep {...makeProps()} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('External (by email)'));
|
||||
|
||||
const input = screen.getByPlaceholderText('signer@example.com');
|
||||
await user.type(input, 'not-an-email');
|
||||
await user.click(screen.getByText('Add'));
|
||||
|
||||
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows duplicate email error when same email is added twice', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep
|
||||
{...makeProps({
|
||||
participants: [{ type: 'external', email: 'existing@example.com' }],
|
||||
})}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('External (by email)'));
|
||||
|
||||
const input = screen.getByPlaceholderText('signer@example.com');
|
||||
await user.type(input, 'existing@example.com');
|
||||
await user.click(screen.getByText('Add'));
|
||||
|
||||
expect(screen.getByText('This email has already been added')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays external participants as Guest badges', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep
|
||||
{...makeProps({
|
||||
participants: [{ type: 'external', email: 'guest@example.com' }],
|
||||
})}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('guest@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Guest')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes participant when close button is clicked', () => {
|
||||
const onParticipantsChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep
|
||||
{...makeProps({
|
||||
participants: [{ type: 'external', email: 'remove@example.com' }],
|
||||
onParticipantsChange,
|
||||
})}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// The remove action icon
|
||||
const removeButtons = screen.getAllByRole('button');
|
||||
const removeBtn = removeButtons.find(
|
||||
(btn) => btn.getAttribute('data-variant') === 'subtle'
|
||||
);
|
||||
expect(removeBtn).toBeDefined();
|
||||
fireEvent.click(removeBtn!);
|
||||
|
||||
expect(onParticipantsChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('shows email invite alert when external participants are present', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep
|
||||
{...makeProps({
|
||||
participants: [{ type: 'external', email: 'invited@example.com' }],
|
||||
})}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/External participants will receive an email invitation/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show email invite alert when only registered users are present', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep
|
||||
{...makeProps({
|
||||
participants: [{ type: 'registered', userId: 1 }],
|
||||
})}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/External participants will receive an email invitation/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onBack when Back button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep {...makeProps({ onBack })} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Back'));
|
||||
expect(onBack).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onNext when Continue button is clicked with participants', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<SelectParticipantsStep
|
||||
{...makeProps({
|
||||
participants: [{ type: 'external', email: 'a@b.com' }],
|
||||
onNext,
|
||||
})}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Continue to Signature Settings'));
|
||||
expect(onNext).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,234 @@
|
||||
import { Button, Stack, Text, Group } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import UserSelector from '@app/components/shared/UserSelector';
|
||||
|
||||
// ─── Shared participant model ─────────────────────────────────────────────────
|
||||
|
||||
export interface Participant {
|
||||
type: 'registered' | 'external';
|
||||
/** Present for registered users */
|
||||
userId?: number;
|
||||
/** Present for external/guest users */
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SelectParticipantsStepProps {
|
||||
selectedUserIds: number[];
|
||||
onSelectedUserIdsChange: (userIds: number[]) => void;
|
||||
participants: Participant[];
|
||||
onParticipantsChange: (participants: Participant[]) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ─── Email validation ─────────────────────────────────────────────────────────
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const SelectParticipantsStep: React.FC<SelectParticipantsStepProps> = ({
|
||||
selectedUserIds,
|
||||
onSelectedUserIdsChange,
|
||||
participants,
|
||||
onParticipantsChange,
|
||||
onBack,
|
||||
onNext,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasParticipants = selectedUserIds.length > 0;
|
||||
// External email input state
|
||||
const [emailInput, setEmailInput] = useState('');
|
||||
const [emailError, setEmailError] = useState('');
|
||||
|
||||
const registeredUserIds = participants
|
||||
.filter((p) => p.type === 'registered' && p.userId != null)
|
||||
.map((p) => p.userId as number);
|
||||
|
||||
function handleRegisteredChange(userIds: number[]) {
|
||||
const external = participants.filter((p) => p.type === 'external');
|
||||
const registered: Participant[] = userIds.map((id) => ({ type: 'registered', userId: id }));
|
||||
onParticipantsChange([...registered, ...external]);
|
||||
}
|
||||
|
||||
function handleAddEmail() {
|
||||
const trimmed = emailInput.trim();
|
||||
if (!EMAIL_RE.test(trimmed)) {
|
||||
setEmailError(
|
||||
t(
|
||||
'groupSigning.steps.selectParticipants.invalidEmail',
|
||||
'Please enter a valid email address'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (participants.some((p) => p.email === trimmed)) {
|
||||
setEmailError(
|
||||
t(
|
||||
'groupSigning.steps.selectParticipants.duplicateEmail',
|
||||
'This email has already been added'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
onParticipantsChange([...participants, { type: 'external', email: trimmed }]);
|
||||
setEmailInput('');
|
||||
setEmailError('');
|
||||
}
|
||||
|
||||
function handleRemove(participant: Participant) {
|
||||
onParticipantsChange(
|
||||
participants.filter((p) =>
|
||||
participant.type === 'registered'
|
||||
? !(p.type === 'registered' && p.userId === participant.userId)
|
||||
: !(p.type === 'external' && p.email === participant.email)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{t('groupSigning.steps.selectParticipants.label', 'Select participants')}
|
||||
</Text>
|
||||
<UserSelector
|
||||
value={selectedUserIds}
|
||||
onChange={onSelectedUserIdsChange}
|
||||
placeholder={t(
|
||||
'groupSigning.steps.selectParticipants.placeholder',
|
||||
'Choose participants to sign...'
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Tabs defaultValue="registered">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="registered" leftSection={<PersonIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered Users')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="external" leftSection={<EmailIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('groupSigning.steps.selectParticipants.tabExternal', 'External (by email)')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{selectedUserIds.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('groupSigning.steps.selectParticipants.count', {
|
||||
count: selectedUserIds.length,
|
||||
defaultValue: '{{count}} participant(s) selected',
|
||||
})}
|
||||
</Text>
|
||||
<Tabs.Panel value="registered" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('groupSigning.steps.selectParticipants.label', 'Select participants')}
|
||||
</Text>
|
||||
<UserSelector
|
||||
value={registeredUserIds}
|
||||
onChange={handleRegisteredChange}
|
||||
placeholder={t(
|
||||
'groupSigning.steps.selectParticipants.placeholder',
|
||||
'Choose participants to sign...'
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="external" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'groupSigning.steps.selectParticipants.externalNote',
|
||||
'An invitation email will be sent with a signing link. No account required.'
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs" align="flex-start">
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
placeholder={t(
|
||||
'groupSigning.steps.selectParticipants.emailPlaceholder',
|
||||
'signer@example.com'
|
||||
)}
|
||||
value={emailInput}
|
||||
onChange={(e) => {
|
||||
setEmailInput(e.currentTarget.value);
|
||||
if (emailError) setEmailError('');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddEmail();
|
||||
}
|
||||
}}
|
||||
error={emailError}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button onClick={handleAddEmail} disabled={disabled} style={{ marginTop: emailError ? 0 : 0 }}>
|
||||
{t('groupSigning.steps.selectParticipants.addEmail', 'Add')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Combined participant list */}
|
||||
{participants.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('groupSigning.steps.selectParticipants.count', {
|
||||
count: participants.length,
|
||||
defaultValue: '{{count}} participant(s)',
|
||||
})}
|
||||
</Text>
|
||||
{participants.map((p, i) => (
|
||||
<Group key={i} gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={p.type === 'external' ? 'orange' : 'blue'}
|
||||
size="xs"
|
||||
>
|
||||
{p.type === 'external'
|
||||
? t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest')
|
||||
: t('groupSigning.steps.selectParticipants.badgeRegistered', 'User')}
|
||||
</Badge>
|
||||
<Text size="sm" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{p.type === 'external' ? p.email : `User #${p.userId}`}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(p)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 14 }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{participants.some((p) => p.type === 'external') && (
|
||||
<Alert color="blue" variant="light" icon={<EmailIcon sx={{ fontSize: 16 }} />}>
|
||||
{t(
|
||||
'groupSigning.steps.selectParticipants.emailInviteNote',
|
||||
'External participants will receive an email invitation with a signing link.'
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onBack}
|
||||
leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('groupSigning.steps.back', 'Back')}
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!hasParticipants || disabled} style={{ flex: 1 }}>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={participants.length === 0 || disabled}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{t('groupSigning.steps.selectParticipants.continue', 'Continue to Signature Settings')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface SessionDetailWorkbenchData {
|
||||
pdfFile: File | null;
|
||||
onFinalize: () => Promise<void>;
|
||||
onLoadSignedPdf: () => Promise<void>;
|
||||
onAddParticipants: (userIds: number[], defaultReason?: string) => Promise<void>;
|
||||
onAddParticipants: (userIds: number[], emails: string[], defaultReason?: string) => Promise<void>;
|
||||
onRemoveParticipant: (participantId: number) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onBack: () => void;
|
||||
@@ -67,9 +67,9 @@ const SessionDetailWorkbenchView = ({ data }: SessionDetailWorkbenchViewProps) =
|
||||
}
|
||||
}, [session.finalized, onRefresh]);
|
||||
|
||||
const handleAddParticipants = async (userIds: number[], defaultReason?: string) => {
|
||||
const handleAddParticipants = async (userIds: number[], emails: string[], defaultReason?: string) => {
|
||||
try {
|
||||
await onAddParticipants(userIds, defaultReason);
|
||||
await onAddParticipants(userIds, emails, defaultReason);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Stack, TextInput, Button, Group } from '@mantine/core';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import UserSelector from '@app/components/shared/UserSelector';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface AddParticipantsFlowProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (userIds: number[], defaultReason?: string) => Promise<void>;
|
||||
onSubmit: (userIds: number[], emails: string[], defaultReason?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
|
||||
@@ -17,19 +32,52 @@ export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [externalEmails, setExternalEmails] = useState<string[]>([]);
|
||||
const [emailInput, setEmailInput] = useState('');
|
||||
const [emailError, setEmailError] = useState('');
|
||||
const [defaultReason, setDefaultReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const totalCount = selectedUserIds.length + externalEmails.length;
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedUserIds([]);
|
||||
setExternalEmails([]);
|
||||
setEmailInput('');
|
||||
setEmailError('');
|
||||
setDefaultReason('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
function handleAddEmail() {
|
||||
const trimmed = emailInput.trim();
|
||||
if (!EMAIL_RE.test(trimmed)) {
|
||||
setEmailError(
|
||||
t(
|
||||
'groupSigning.steps.selectParticipants.invalidEmail',
|
||||
'Please enter a valid email address'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (externalEmails.includes(trimmed)) {
|
||||
setEmailError(
|
||||
t(
|
||||
'groupSigning.steps.selectParticipants.duplicateEmail',
|
||||
'This email has already been added'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
setExternalEmails((prev) => [...prev, trimmed]);
|
||||
setEmailInput('');
|
||||
setEmailError('');
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(selectedUserIds, defaultReason.trim() || undefined);
|
||||
await onSubmit(selectedUserIds, externalEmails, defaultReason.trim() || undefined);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add participants:', error);
|
||||
@@ -46,18 +94,87 @@ export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<UserSelector
|
||||
value={selectedUserIds}
|
||||
onChange={setSelectedUserIds}
|
||||
placeholder={t('certSign.collab.sessionDetail.selectUsers', 'Select users...')}
|
||||
/>
|
||||
<Tabs defaultValue="registered">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="registered" leftSection={<PersonIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered Users')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="external" leftSection={<EmailIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('groupSigning.steps.selectParticipants.tabExternal', 'External (by email)')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="registered" pt="sm">
|
||||
<UserSelector
|
||||
value={selectedUserIds}
|
||||
onChange={setSelectedUserIds}
|
||||
placeholder={t('certSign.collab.sessionDetail.selectUsers', 'Select users...')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="external" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'groupSigning.steps.selectParticipants.externalNote',
|
||||
'An invitation email will be sent with a signing link. No account required.'
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs" align="flex-start">
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
placeholder="signer@example.com"
|
||||
value={emailInput}
|
||||
onChange={(e) => {
|
||||
setEmailInput(e.currentTarget.value);
|
||||
if (emailError) setEmailError('');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddEmail();
|
||||
}
|
||||
}}
|
||||
error={emailError}
|
||||
/>
|
||||
<Button onClick={handleAddEmail} variant="default">
|
||||
{t('groupSigning.steps.selectParticipants.addEmail', 'Add')}
|
||||
</Button>
|
||||
</Group>
|
||||
{externalEmails.map((email) => (
|
||||
<Group key={email} gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="orange" size="xs">
|
||||
{t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest')}
|
||||
</Badge>
|
||||
<Text size="sm">{email}</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => setExternalEmails((prev) => prev.filter((e) => e !== email))}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 14 }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<TextInput
|
||||
label={t('certSign.reason', 'Default Reason')}
|
||||
description={t('certSign.collab.addParticipants.reasonHelp', 'Pre-set a signing reason for these participants (optional, they can override when signing)')}
|
||||
description={t(
|
||||
'certSign.collab.addParticipants.reasonHelp',
|
||||
'Pre-set a signing reason for these participants (optional, they can override when signing)'
|
||||
)}
|
||||
value={defaultReason}
|
||||
onChange={(e) => setDefaultReason(e.currentTarget.value)}
|
||||
placeholder={t('certSign.collab.addParticipants.reasonPlaceholder', 'e.g. Approval, Review...')}
|
||||
placeholder={t(
|
||||
'certSign.collab.addParticipants.reasonPlaceholder',
|
||||
'e.g. Approval, Review...'
|
||||
)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
@@ -68,12 +185,12 @@ export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={selectedUserIds.length === 0}
|
||||
disabled={totalCount === 0}
|
||||
leftSection={<AddIcon sx={{ fontSize: 16 }} />}
|
||||
color="green"
|
||||
>
|
||||
{t('certSign.collab.addParticipants.add', 'Add {{count}} Participant(s)', {
|
||||
count: selectedUserIds.length,
|
||||
count: totalCount,
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
|
||||
// ── i18n ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') return fallback;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
initReactI18next: { type: '3rdParty', init: vi.fn() },
|
||||
}));
|
||||
|
||||
// ── react-router-dom: useParams returns our injected token ──────────────────
|
||||
|
||||
const mockToken = 'test-token-abc';
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useParams: () => ({ token: mockToken }),
|
||||
}));
|
||||
|
||||
// ── Heavy sub-components — stub them out so we don't need full canvas / MUI ─
|
||||
|
||||
vi.mock('@app/components/shared/wetSignature/DrawSignatureCanvas', () => ({
|
||||
DrawSignatureCanvas: ({ onChange }: { onChange: (v: string | null) => void }) => (
|
||||
<button onClick={() => onChange('sig-data-stub')}>Draw</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@app/components/shared/wetSignature/SignatureTypeSelector', () => ({
|
||||
SignatureTypeSelector: () => <div data-testid="sig-type-selector" />,
|
||||
SignatureType: {},
|
||||
}));
|
||||
|
||||
vi.mock('@app/components/shared/wetSignature/TypeSignatureText', () => ({
|
||||
TypeSignatureText: () => <div data-testid="type-sig-text" />,
|
||||
}));
|
||||
|
||||
vi.mock('@app/components/shared/wetSignature/UploadSignatureImage', () => ({
|
||||
UploadSignatureImage: () => <div data-testid="upload-sig-image" />,
|
||||
}));
|
||||
|
||||
vi.mock('@app/components/shared/signing/GuestCertificateChooser', () => ({
|
||||
GuestCertificateChooser: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) => (
|
||||
<div data-testid="cert-chooser" data-value={value}>
|
||||
<button onClick={() => onChange('GUEST_CERT')}>Auto Cert</button>
|
||||
<button onClick={() => onChange('P12')}>Upload Cert</button>
|
||||
</div>
|
||||
),
|
||||
GuestCertType: {},
|
||||
}));
|
||||
|
||||
vi.mock('@app/types/signingSession', () => ({}));
|
||||
|
||||
// ── Component under test ────────────────────────────────────────────────────
|
||||
|
||||
import GuestSignPage from './GuestSignPage';
|
||||
|
||||
// ── Test wrapper ────────────────────────────────────────────────────────────
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// ── Fetch helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function mockFetchSuccess(
|
||||
sessionData = { sessionId: '1', documentName: 'Contract.pdf', ownerEmail: 'owner@example.com' },
|
||||
participantData = { id: 1, email: 'guest@example.com', name: 'Guest', status: 'VIEWED' }
|
||||
) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/session')) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(sessionData) });
|
||||
}
|
||||
if (url.includes('/details')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(participantData),
|
||||
});
|
||||
}
|
||||
if (url.includes('/document')) {
|
||||
return Promise.resolve({ ok: true, status: 200 });
|
||||
}
|
||||
// submit-signature or decline
|
||||
return Promise.resolve({ ok: true, status: 200 });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchForbidden() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 403 })
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GuestSignPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state on mount before fetch resolves', () => {
|
||||
// Never resolve the fetch — stays in loading state
|
||||
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/loading signing session/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows expired message when fetch returns 403', async () => {
|
||||
mockFetchForbidden();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/This signing link has expired\./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows signed success state when participant status is SIGNED', async () => {
|
||||
mockFetchSuccess(undefined, {
|
||||
id: 1,
|
||||
email: 'guest@example.com',
|
||||
name: 'Guest',
|
||||
status: 'SIGNED',
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Your signature has been submitted successfully\./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows declined state when participant status is DECLINED', async () => {
|
||||
mockFetchSuccess(undefined, {
|
||||
id: 1,
|
||||
email: 'guest@example.com',
|
||||
name: 'Guest',
|
||||
status: 'DECLINED',
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/You have declined this signing request\./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the signing form in ready state', async () => {
|
||||
mockFetchSuccess();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sign Document')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contract.pdf')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows auto-cert chooser selected by default', async () => {
|
||||
mockFetchSuccess();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const chooser = screen.getByTestId('cert-chooser');
|
||||
expect(chooser).toHaveAttribute('data-value', 'GUEST_CERT');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits with GUEST_CERT when auto cert is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchSuccess();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sign Document')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submitButton = screen.getByText('Submit Signature');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
const submitCall = fetchMock.mock.calls.find(([url]) =>
|
||||
String(url).includes('submit-signature')
|
||||
);
|
||||
expect(submitCall).toBeDefined();
|
||||
const body = submitCall![1]!.body as FormData;
|
||||
expect(body.get('certType')).toBe('GUEST_CERT');
|
||||
expect(body.get('participantToken')).toBe(mockToken);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success state after successful submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchSuccess();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sign Document')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Submit Signature'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Your signature has been submitted successfully\./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens decline confirmation modal when Decline is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchSuccess();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sign Document')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the outer Decline button (variant="subtle")
|
||||
const outerDeclineBtn = screen.getAllByRole('button').find(
|
||||
(btn) => btn.getAttribute('data-variant') === 'subtle'
|
||||
);
|
||||
expect(outerDeclineBtn).toBeDefined();
|
||||
await user.click(outerDeclineBtn!);
|
||||
|
||||
// Modal confirmation dialog should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Decline signing?')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to decline/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Cancel button should close the modal
|
||||
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Decline signing?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error state when fetch fails with non-403 error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 500 })
|
||||
);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<GuestSignPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
|
||||
import { DrawSignatureCanvas } from '@app/components/shared/wetSignature/DrawSignatureCanvas';
|
||||
import { SignatureTypeSelector, SignatureType } from '@app/components/shared/wetSignature/SignatureTypeSelector';
|
||||
import { TypeSignatureText } from '@app/components/shared/wetSignature/TypeSignatureText';
|
||||
import { UploadSignatureImage } from '@app/components/shared/wetSignature/UploadSignatureImage';
|
||||
import { GuestCertificateChooser, GuestCertType } from '@app/components/shared/signing/GuestCertificateChooser';
|
||||
import type { WetSignatureMetadata } from '@app/types/signingSession';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
documentName: string;
|
||||
ownerEmail: string;
|
||||
message?: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
interface ParticipantDetails {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
status: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
type PageState = 'loading' | 'ready' | 'expired' | 'signed' | 'declined' | 'error';
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function GuestSignPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Page state
|
||||
const [pageState, setPageState] = useState<PageState>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||
const [participant, setParticipant] = useState<ParticipantDetails | null>(null);
|
||||
|
||||
// Signature state
|
||||
const [sigType, setSigType] = useState<SignatureType>('draw');
|
||||
const [sigData, setSigData] = useState<string | null>(null);
|
||||
|
||||
// Certificate state
|
||||
const [certType, setCertType] = useState<GuestCertType>('GUEST_CERT');
|
||||
const [p12File, setP12File] = useState<File | null>(null);
|
||||
const [certPassword, setCertPassword] = useState('');
|
||||
|
||||
// Submission state
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [declineOpen, { open: openDecline, close: closeDecline }] = useDisclosure(false);
|
||||
const [declining, setDeclining] = useState(false);
|
||||
|
||||
// ── Load session on mount ──────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setPageState('error');
|
||||
setErrorMessage('Missing signing token.');
|
||||
return;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [sessionRes, detailsRes] = await Promise.all([
|
||||
fetch(`/api/v1/workflow/participant/session?token=${encodeURIComponent(token!)}`),
|
||||
fetch(`/api/v1/workflow/participant/details?token=${encodeURIComponent(token!)}`),
|
||||
]);
|
||||
|
||||
if (sessionRes.status === 403 || detailsRes.status === 403) {
|
||||
setPageState('expired');
|
||||
return;
|
||||
}
|
||||
if (!sessionRes.ok || !detailsRes.ok) {
|
||||
setPageState('error');
|
||||
setErrorMessage('Unable to load signing session.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData: SessionInfo = await sessionRes.json();
|
||||
const participantData: ParticipantDetails = await detailsRes.json();
|
||||
|
||||
setSession(sessionData);
|
||||
setParticipant(participantData);
|
||||
|
||||
if (participantData.status === 'SIGNED') {
|
||||
setPageState('signed');
|
||||
} else if (participantData.status === 'DECLINED') {
|
||||
setPageState('declined');
|
||||
} else {
|
||||
setPageState('ready');
|
||||
}
|
||||
} catch {
|
||||
setPageState('error');
|
||||
setErrorMessage('An unexpected error occurred.');
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}, [token]);
|
||||
|
||||
// ── Submit signature ───────────────────────────────────────────────────
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!token) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('participantToken', token);
|
||||
formData.append('certType', certType);
|
||||
|
||||
if (certType === 'P12') {
|
||||
if (!p12File) {
|
||||
alert(t('guestSigning.certFileRequired', 'Please select a certificate file.'));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
formData.append('p12File', p12File);
|
||||
formData.append('password', certPassword);
|
||||
}
|
||||
|
||||
if (sigData) {
|
||||
const wetSig: WetSignatureMetadata = {
|
||||
type: sigType === 'type' ? 'text' : sigType === 'draw' ? 'canvas' : 'image',
|
||||
data: sigData,
|
||||
page: 0,
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 60,
|
||||
};
|
||||
formData.append('wetSignaturesData', JSON.stringify([wetSig]));
|
||||
}
|
||||
|
||||
const res = await fetch('/api/v1/workflow/participant/submit-signature', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
setErrorMessage(body || t('guestSigning.submitError', 'Failed to submit signature.'));
|
||||
setPageState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setPageState('signed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decline ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDecline() {
|
||||
if (!token) return;
|
||||
setDeclining(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/workflow/participant/decline?token=${encodeURIComponent(token)}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (res.ok) {
|
||||
setPageState('declined');
|
||||
}
|
||||
} finally {
|
||||
setDeclining(false);
|
||||
closeDecline();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render helpers ─────────────────────────────────────────────────────
|
||||
|
||||
if (pageState === 'loading') {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text>{t('guestSigning.loadingSession', 'Loading signing session...')}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageState === 'expired') {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Paper shadow="sm" p="xl" maw={480} w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<ErrorOutlineIcon style={{ fontSize: 48, color: '#fa5252' }} />
|
||||
<Title order={3}>{t('guestSigning.expiredToken', 'This signing link has expired.')}</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t('guestSigning.expiredNote', 'Please contact the document owner for a new link.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageState === 'signed') {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Paper shadow="sm" p="xl" maw={480} w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<CheckCircleIcon style={{ fontSize: 48, color: '#40c057' }} />
|
||||
<Title order={3}>{t('guestSigning.submitSuccess', 'Your signature has been submitted successfully.')}</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t('guestSigning.signedNote', 'The document owner has been notified. You may close this window.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageState === 'declined') {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Paper shadow="sm" p="xl" maw={480} w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<ErrorOutlineIcon style={{ fontSize: 48, color: '#fab005' }} />
|
||||
<Title order={3}>{t('guestSigning.declineSuccess', 'You have declined this signing request.')}</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t('guestSigning.declinedNote', 'The document owner has been notified. You may close this window.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageState === 'error') {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Paper shadow="sm" p="xl" maw={480} w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<ErrorOutlineIcon style={{ fontSize: 48, color: '#fa5252' }} />
|
||||
<Title order={3}>{t('guestSigning.errorTitle', 'Something went wrong')}</Title>
|
||||
<Text c="dimmed" ta="center">{errorMessage}</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main signing form ──────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Center py="xl" px="md">
|
||||
<Paper shadow="sm" p="xl" maw={680} w="100%">
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<Stack gap="xs">
|
||||
<Title order={2}>{t('guestSigning.pageTitle', 'Sign Document')}</Title>
|
||||
{session && (
|
||||
<>
|
||||
<Text size="lg" fw={500}>{session.documentName}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('guestSigning.requestedBy', 'Requested by {{owner}}', {
|
||||
owner: session.ownerEmail,
|
||||
})}
|
||||
</Text>
|
||||
{session.dueDate && (
|
||||
<Text c="orange" size="sm">
|
||||
{t('guestSigning.dueDate', 'Due {{date}}', { date: session.dueDate })}
|
||||
</Text>
|
||||
)}
|
||||
{session.message && (
|
||||
<Alert color="gray" variant="light">
|
||||
{session.message}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* PDF preview */}
|
||||
{token && (
|
||||
<Stack gap="xs">
|
||||
<Text fw={500} size="sm">
|
||||
{t('guestSigning.documentPreview', 'Document')}
|
||||
</Text>
|
||||
<iframe
|
||||
src={`/api/v1/workflow/participant/document?token=${encodeURIComponent(token)}`}
|
||||
title="Document to sign"
|
||||
style={{ width: '100%', height: 400, border: '1px solid #dee2e6', borderRadius: 4 }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Certificate chooser */}
|
||||
<GuestCertificateChooser
|
||||
value={certType}
|
||||
onChange={setCertType}
|
||||
onFileChange={setP12File}
|
||||
onPasswordChange={setCertPassword}
|
||||
p12File={p12File}
|
||||
password={certPassword}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Wet signature */}
|
||||
<Stack gap="sm">
|
||||
<Text fw={500} size="sm">
|
||||
{t('guestSigning.signatureTitle', 'Your Signature')}
|
||||
</Text>
|
||||
<SignatureTypeSelector value={sigType} onChange={setSigType} />
|
||||
{sigType === 'draw' && (
|
||||
<DrawSignatureCanvas signature={sigData} onChange={setSigData} />
|
||||
)}
|
||||
{sigType === 'type' && (
|
||||
<TypeSignatureText value={sigData ?? ''} onChange={setSigData} />
|
||||
)}
|
||||
{sigType === 'upload' && (
|
||||
<UploadSignatureImage value={sigData} onChange={setSigData} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Actions */}
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" color="red" onClick={openDecline} disabled={submitting}>
|
||||
{t('guestSigning.declineButton', 'Decline')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={submitting}>
|
||||
{t('guestSigning.submitButton', 'Submit Signature')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Decline confirmation modal */}
|
||||
<Modal
|
||||
opened={declineOpen}
|
||||
onClose={closeDecline}
|
||||
title={t('guestSigning.declineConfirmTitle', 'Decline signing?')}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t(
|
||||
'guestSigning.declineConfirmBody',
|
||||
"Are you sure you want to decline? This action cannot be undone."
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDecline}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDecline} loading={declining}>
|
||||
{t('guestSigning.declineButton', 'Decline')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* E2E tests for the Guest Signing flow (/sign/:token).
|
||||
*
|
||||
* These tests use Playwright's route mocking to intercept all API calls, so no
|
||||
* running backend is required. They cover the full page-state machine from the
|
||||
* browser perspective.
|
||||
*
|
||||
* Run: npx playwright test src/core/tests/guestSigning/GuestSigningE2E.spec.ts
|
||||
*/
|
||||
|
||||
import { test, expect, Page, Route } from '@playwright/test';
|
||||
|
||||
// ─── Shared fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
const TOKEN = 'test-share-token-abc';
|
||||
const SIGN_URL = `/sign/${TOKEN}`;
|
||||
|
||||
const SESSION_PAYLOAD = {
|
||||
sessionId: 'session-1',
|
||||
documentName: 'Contract_2026.pdf',
|
||||
ownerEmail: 'owner@company.com',
|
||||
message: 'Please review and sign by end of week.',
|
||||
};
|
||||
|
||||
const PARTICIPANT_PENDING = {
|
||||
id: 1,
|
||||
email: 'guest@example.com',
|
||||
name: 'Guest Signer',
|
||||
status: 'PENDING',
|
||||
};
|
||||
|
||||
const PARTICIPANT_SIGNED = { ...PARTICIPANT_PENDING, status: 'SIGNED' };
|
||||
const PARTICIPANT_DECLINED = { ...PARTICIPANT_PENDING, status: 'DECLINED' };
|
||||
|
||||
// ─── Route helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function mockHappyPath(page: Page) {
|
||||
await page.route('**/workflow/participant/session**', (route: Route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
|
||||
);
|
||||
await page.route('**/workflow/participant/details**', (route: Route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
|
||||
);
|
||||
await page.route('**/workflow/participant/document**', (route: Route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/pdf', body: Buffer.from('%PDF-1.4 stub') })
|
||||
);
|
||||
await page.route('**/workflow/participant/submit-signature', (route: Route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'SIGNED' }) })
|
||||
);
|
||||
await page.route('**/workflow/participant/decline**', (route: Route) =>
|
||||
route.fulfill({ status: 200 })
|
||||
);
|
||||
}
|
||||
|
||||
async function mockForbidden(page: Page) {
|
||||
await page.route('**/workflow/participant/**', (route: Route) =>
|
||||
route.fulfill({ status: 403 })
|
||||
);
|
||||
}
|
||||
|
||||
async function mockAlreadySigned(page: Page) {
|
||||
await page.route('**/workflow/participant/session**', (route: Route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
|
||||
);
|
||||
await page.route('**/workflow/participant/details**', (route: Route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_SIGNED) })
|
||||
);
|
||||
}
|
||||
|
||||
async function mockAlreadyDeclined(page: Page) {
|
||||
await page.route('**/workflow/participant/session**', (route: Route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
|
||||
);
|
||||
await page.route('**/workflow/participant/details**', (route: Route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_DECLINED) })
|
||||
);
|
||||
}
|
||||
|
||||
async function mockServerError(page: Page) {
|
||||
await page.route('**/workflow/participant/**', (route: Route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('GuestSignPage', () => {
|
||||
|
||||
// ── Page state machine ──────────────────────────────────────────────────
|
||||
|
||||
test('shows loading spinner before API responds', async ({ page }) => {
|
||||
// Delay the session response so we can catch the loading state
|
||||
await page.route('**/workflow/participant/session**', async (route) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) });
|
||||
});
|
||||
await page.route('**/workflow/participant/details**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
|
||||
);
|
||||
|
||||
await page.goto(SIGN_URL);
|
||||
await expect(page.getByText(/loading signing session/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows expired message for 403 response', async ({ page }) => {
|
||||
await mockForbidden(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText(/this signing link has expired/i)).toBeVisible();
|
||||
await expect(page.getByText(/contact the document owner for a new link/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows already-signed state when participant status is SIGNED', async ({ page }) => {
|
||||
await mockAlreadySigned(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText(/your signature has been submitted successfully/i)).toBeVisible();
|
||||
await expect(page.getByText(/you may close this window/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows declined state when participant status is DECLINED', async ({ page }) => {
|
||||
await mockAlreadyDeclined(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText(/you have declined this signing request/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows error state on 500 server error', async ({ page }) => {
|
||||
await mockServerError(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText(/something went wrong/i)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Ready (signing form) ────────────────────────────────────────────────
|
||||
|
||||
test('renders signing form with document details', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText('Sign Document')).toBeVisible();
|
||||
await expect(page.getByText('Contract_2026.pdf')).toBeVisible();
|
||||
await expect(page.getByText(/Requested by owner@company.com/i)).toBeVisible();
|
||||
await expect(page.getByText('Please review and sign by end of week.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows document preview iframe', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.locator('iframe[title="Document to sign"]')).toBeVisible();
|
||||
const iframeSrc = await page.locator('iframe[title="Document to sign"]').getAttribute('src');
|
||||
expect(iframeSrc).toContain(TOKEN);
|
||||
expect(iframeSrc).toContain('document');
|
||||
});
|
||||
|
||||
test('auto-cert option is selected by default', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
// The auto-cert radio should be checked
|
||||
const autoRadio = page.getByLabel(/use auto-generated certificate/i);
|
||||
await expect(autoRadio).toBeChecked();
|
||||
});
|
||||
|
||||
test('shows auto-cert info alert when auto cert is selected', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText(/a certificate will be generated using your email address/i)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Certificate chooser ─────────────────────────────────────────────────
|
||||
|
||||
test('switching to P12 shows file input and password field', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await page.getByLabel(/upload my own certificate/i).check();
|
||||
|
||||
await expect(page.getByLabel(/certificate file/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/certificate password/i)).toBeVisible();
|
||||
// Auto-cert alert should be hidden
|
||||
await expect(page.getByText(/a certificate will be generated using your email address/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('switching back to auto-cert hides file inputs', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await page.getByLabel(/upload my own certificate/i).check();
|
||||
await expect(page.getByLabel(/certificate file/i)).toBeVisible();
|
||||
|
||||
await page.getByLabel(/use auto-generated certificate/i).check();
|
||||
await expect(page.getByLabel(/certificate file/i)).not.toBeVisible();
|
||||
await expect(page.getByText(/a certificate will be generated using your email address/i)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Submit signature ────────────────────────────────────────────────────
|
||||
|
||||
test('submits with GUEST_CERT and shows success state', async ({ page }) => {
|
||||
let capturedFormData: Record<string, string> = {};
|
||||
|
||||
await page.route('**/workflow/participant/session**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
|
||||
);
|
||||
await page.route('**/workflow/participant/details**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
|
||||
);
|
||||
await page.route('**/workflow/participant/document**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/pdf', body: Buffer.from('%PDF-1.4') })
|
||||
);
|
||||
await page.route('**/workflow/participant/submit-signature', async (route) => {
|
||||
const request = route.request();
|
||||
const postData = request.postData();
|
||||
if (postData) capturedFormData.raw = postData;
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'SIGNED' }) });
|
||||
});
|
||||
|
||||
await page.goto(SIGN_URL);
|
||||
await expect(page.getByText('Sign Document')).toBeVisible();
|
||||
|
||||
// Submit with default auto-cert
|
||||
await page.getByRole('button', { name: /submit signature/i }).click();
|
||||
|
||||
// Should transition to success state
|
||||
await expect(page.getByText(/your signature has been submitted successfully/i)).toBeVisible();
|
||||
|
||||
// Verify certType was GUEST_CERT
|
||||
expect(capturedFormData.raw).toContain('GUEST_CERT');
|
||||
expect(capturedFormData.raw).toContain(TOKEN);
|
||||
});
|
||||
|
||||
test('shows error message when submission fails', async ({ page }) => {
|
||||
await page.route('**/workflow/participant/session**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
|
||||
);
|
||||
await page.route('**/workflow/participant/details**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
|
||||
);
|
||||
await page.route('**/workflow/participant/document**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/pdf', body: Buffer.from('%PDF-1.4') })
|
||||
);
|
||||
await page.route('**/workflow/participant/submit-signature', (route) =>
|
||||
route.fulfill({ status: 400, body: 'Session has expired' })
|
||||
);
|
||||
|
||||
await page.goto(SIGN_URL);
|
||||
await expect(page.getByText('Sign Document')).toBeVisible();
|
||||
await page.getByRole('button', { name: /submit signature/i }).click();
|
||||
|
||||
await expect(page.getByText(/something went wrong/i)).toBeVisible();
|
||||
await expect(page.getByText('Session has expired')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Submit Signature button shows loading state while in flight', async ({ page }) => {
|
||||
await page.route('**/workflow/participant/session**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
|
||||
);
|
||||
await page.route('**/workflow/participant/details**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
|
||||
);
|
||||
await page.route('**/workflow/participant/document**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/pdf', body: Buffer.from('%PDF-1.4') })
|
||||
);
|
||||
await page.route('**/workflow/participant/submit-signature', async (route) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'SIGNED' }) });
|
||||
});
|
||||
|
||||
await page.goto(SIGN_URL);
|
||||
await expect(page.getByText('Sign Document')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /submit signature/i }).click();
|
||||
|
||||
// Button should be in loading/disabled state
|
||||
const submitBtn = page.getByRole('button', { name: /submit signature/i });
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Eventually success
|
||||
await expect(page.getByText(/your signature has been submitted successfully/i)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Decline flow ────────────────────────────────────────────────────────
|
||||
|
||||
test('decline button opens confirmation modal', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText('Sign Document')).toBeVisible();
|
||||
await page.getByRole('button', { name: /decline/i }).first().click();
|
||||
|
||||
await expect(page.getByText('Decline signing?')).toBeVisible();
|
||||
await expect(page.getByText(/are you sure you want to decline/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Cancel in decline modal closes modal without declining', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await page.getByRole('button', { name: /decline/i }).first().click();
|
||||
await expect(page.getByText('Decline signing?')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
|
||||
await expect(page.getByText('Decline signing?')).not.toBeVisible();
|
||||
// Signing form still present
|
||||
await expect(page.getByRole('button', { name: /submit signature/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('confirming decline transitions to declined state', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await page.getByRole('button', { name: /decline/i }).first().click();
|
||||
await expect(page.getByText('Decline signing?')).toBeVisible();
|
||||
|
||||
// Click the confirm Decline button inside the modal
|
||||
const modalDeclineBtn = page.getByRole('dialog').getByRole('button', { name: /decline/i });
|
||||
await modalDeclineBtn.click();
|
||||
|
||||
await expect(page.getByText(/you have declined this signing request/i)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Accessibility ───────────────────────────────────────────────────────
|
||||
|
||||
test('Submit Signature button is initially enabled', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText('Sign Document')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /submit signature/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('Decline button is initially enabled', async ({ page }) => {
|
||||
await mockHappyPath(page);
|
||||
await page.goto(SIGN_URL);
|
||||
|
||||
await expect(page.getByText('Sign Document')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /decline/i }).first()).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import InviteAccept from "@app/routes/InviteAccept";
|
||||
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||
import ParticipantView from "@app/components/workflow/ParticipantView";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import GuestSignPage from "@app/routes/GuestSignPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
@@ -42,6 +43,17 @@ function ParticipantViewPage() {
|
||||
return <ParticipantView token={token} />;
|
||||
}
|
||||
|
||||
// Minimal providers for guest signing - token-based, no authentication required
|
||||
function GuestSigningProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
{children}
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
@@ -66,6 +78,16 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Guest signing route - token-based, no authentication required */}
|
||||
<Route
|
||||
path="/sign/:token"
|
||||
element={
|
||||
<GuestSigningProviders>
|
||||
<GuestSignPage />
|
||||
</GuestSigningProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
|
||||
Reference in New Issue
Block a user