diff --git a/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx b/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx index 112093655..02edfabda 100644 --- a/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx +++ b/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx @@ -98,6 +98,7 @@ const decisionFragment = graphql` fragment DocumentApprovePageDecisionFragment on DocumentVersionApprovalDecision { id state + consentText canApprove: permission(action: "core:document-version:approve") canReject: permission(action: "core:document-version:reject") } @@ -350,12 +351,12 @@ function ViewerDecision(props: { }); }} > - {__("Approve")} + {__("Review and approve")} )}

- {__("By clicking Approve, I consent to approve this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature.")} + {decision.consentText}

-

- {__( - "By clicking 'I acknowledge and agree', your digital signature will be recorded.", - )} +

+ {versionData.consentText}

); diff --git a/apps/trust/src/pages/NDAPage.tsx b/apps/trust/src/pages/NDAPage.tsx index a6b44d89f..00a30f6c2 100644 --- a/apps/trust/src/pages/NDAPage.tsx +++ b/apps/trust/src/pages/NDAPage.tsx @@ -223,11 +223,7 @@ export function NDAPage(props: { return ; } - const consentText = ndaSignature?.consentText - ? ndaSignature.consentText - : __( - "By clicking Review & Sign, you agree to the terms of this NDA. If you have questions about the NDA, please contact security@probo.com.", - ); + const consentText = ndaSignature?.consentText; return (
@@ -297,7 +293,7 @@ export function NDAPage(props: { > {isFailed ? __("Try again") - : __("Accept")} + : __("Review and sign")} )}
diff --git a/e2e/console/document_version_test.go b/e2e/console/document_version_test.go index 22764a061..44cd1a2d2 100644 --- a/e2e/console/document_version_test.go +++ b/e2e/console/document_version_test.go @@ -2051,3 +2051,145 @@ func TestDocumentVersion_RequestSignatureDeduplicatesAcrossMinors(t *testing.T) assertRequestedSignatureCount(t, owner, v10ID, 1) assertRequestedSignatureCount(t, owner, v11ID, 1) } + +// signDocumentVersion signs the version as the authenticated client and returns +// the resulting signature node's id, state and signing time. +func signDocumentVersion(t *testing.T, signer *testutil.Client, versionID string) (id, state, signedAt string) { + t.Helper() + + var result struct { + SignDocument struct { + DocumentVersionSignature struct { + ID string `json:"id"` + State string `json:"state"` + SignedAt string `json:"signedAt"` + } `json:"documentVersionSignature"` + } `json:"signDocument"` + } + + err := signer.Execute(` + mutation($input: SignDocumentInput!) { + signDocument(input: $input) { + documentVersionSignature { id state signedAt } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionID, + }, + }, &result) + require.NoError(t, err) + + return result.SignDocument.DocumentVersionSignature.ID, + result.SignDocument.DocumentVersionSignature.State, + result.SignDocument.DocumentVersionSignature.SignedAt +} + +// signDocumentVersionMutation is the raw mutation used by the negative-path +// signing tests so they can assert the request is rejected. +const signDocumentVersionMutation = ` + mutation($input: SignDocumentInput!) { + signDocument(input: $input) { + documentVersionSignature { id state } + } + } +` + +// TestDocumentVersion_SignDocument verifies that a requested signature can be +// signed by the signatory, transitioning REQUESTED -> SIGNED and recording the +// signing time. Signing exercises the electronic-signature integration end to +// end: PDF export, upload, and esign create-and-accept. +func TestDocumentVersion_SignDocument(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + ownerProfileID := owner.GetProfileID().String() + + requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID) + + id, state, signedAt := signDocumentVersion(t, owner, publishedVersionID) + + assert.NotEmpty(t, id) + assert.Equal(t, "SIGNED", state) + assert.NotEmpty(t, signedAt) +} + +// TestDocumentVersion_SignDocumentWithoutRequestFails verifies that a version +// cannot be signed unless a signature was first requested for the signatory. +func TestDocumentVersion_SignDocumentWithoutRequestFails(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + + _ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + }, + }) +} + +// TestDocumentVersion_SignDocumentTwiceFails verifies that an already-signed +// signature cannot be signed again. +func TestDocumentVersion_SignDocumentTwiceFails(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + ownerProfileID := owner.GetProfileID().String() + + requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID) + signDocumentVersion(t, owner, publishedVersionID) + + _ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + }, + }) +} + +// TestDocumentVersion_SignArchivedDocumentFails verifies that a document +// archived after its signature was requested can no longer be signed. This +// guards the archived/published preconditions that are re-validated inside the +// signing transaction so a state change between request and sign is honored. +func TestDocumentVersion_SignArchivedDocumentFails(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + ownerProfileID := owner.GetProfileID().String() + + requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID) + + _, err := owner.Do(` + mutation($input: ArchiveDocumentInput!) { + archiveDocument(input: $input) { + document { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + }, + }) + require.NoError(t, err) + + _ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + }, + }) +} diff --git a/pkg/coredata/document_version_signature.go b/pkg/coredata/document_version_signature.go index c6f68772d..85ffe0d97 100644 --- a/pkg/coredata/document_version_signature.go +++ b/pkg/coredata/document_version_signature.go @@ -32,15 +32,16 @@ import ( type ( DocumentVersionSignature struct { - ID gid.GID `json:"id" db:"id"` - OrganizationID gid.GID `json:"-" db:"organization_id"` - DocumentVersionID gid.GID `json:"document_version_id" db:"document_version_id"` - State DocumentVersionSignatureState `json:"state" db:"state"` - SignedBy gid.GID `json:"signed_by" db:"signed_by_profile_id"` - SignedAt *time.Time `json:"signed_at" db:"signed_at"` - RequestedAt time.Time `json:"requested_at" db:"requested_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID gid.GID `json:"id" db:"id"` + OrganizationID gid.GID `json:"-" db:"organization_id"` + DocumentVersionID gid.GID `json:"document_version_id" db:"document_version_id"` + State DocumentVersionSignatureState `json:"state" db:"state"` + SignedBy gid.GID `json:"signed_by" db:"signed_by_profile_id"` + SignedAt *time.Time `json:"signed_at" db:"signed_at"` + RequestedAt time.Time `json:"requested_at" db:"requested_at"` + ElectronicSignatureID *gid.GID `json:"-" db:"electronic_signature_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } DocumentVersionSignatures []*DocumentVersionSignature @@ -120,6 +121,7 @@ SELECT signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at FROM @@ -181,6 +183,7 @@ major_signatures AS ( dvs.signed_by_profile_id, dvs.signed_at, dvs.requested_at, + dvs.electronic_signature_id, dvs.created_at, dvs.updated_at FROM document_version_signatures dvs @@ -195,6 +198,7 @@ SELECT signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at FROM @@ -246,6 +250,7 @@ SELECT signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at FROM @@ -290,6 +295,7 @@ INSERT INTO document_version_signatures ( signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at ) VALUES ( @@ -301,22 +307,24 @@ INSERT INTO document_version_signatures ( @signed_by_profile_id, @signed_at, @requested_at, + @electronic_signature_id, @created_at, @updated_at ) ` args := pgx.StrictNamedArgs{ - "id": pvs.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": pvs.OrganizationID, - "document_version_id": pvs.DocumentVersionID, - "state": pvs.State, - "signed_by_profile_id": pvs.SignedBy, - "signed_at": pvs.SignedAt, - "requested_at": pvs.RequestedAt, - "created_at": pvs.CreatedAt, - "updated_at": pvs.UpdatedAt, + "id": pvs.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": pvs.OrganizationID, + "document_version_id": pvs.DocumentVersionID, + "state": pvs.State, + "signed_by_profile_id": pvs.SignedBy, + "signed_at": pvs.SignedAt, + "requested_at": pvs.RequestedAt, + "electronic_signature_id": pvs.ElectronicSignatureID, + "created_at": pvs.CreatedAt, + "updated_at": pvs.UpdatedAt, } _, err := conn.Exec(ctx, q, args) @@ -357,6 +365,7 @@ SELECT document_version_signatures.signed_by_profile_id, document_version_signatures.signed_at, document_version_signatures.requested_at, + document_version_signatures.electronic_signature_id, document_version_signatures.created_at, document_version_signatures.updated_at FROM @@ -401,6 +410,7 @@ SET state = @state, signed_by_profile_id = @signed_by_profile_id, signed_at = @signed_at, + electronic_signature_id = @electronic_signature_id, updated_at = @updated_at WHERE %s @@ -410,11 +420,12 @@ WHERE q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{ - "id": pvs.ID, - "state": pvs.State, - "signed_by_profile_id": pvs.SignedBy, - "signed_at": pvs.SignedAt, - "updated_at": pvs.UpdatedAt, + "id": pvs.ID, + "state": pvs.State, + "signed_by_profile_id": pvs.SignedBy, + "signed_at": pvs.SignedAt, + "electronic_signature_id": pvs.ElectronicSignatureID, + "updated_at": pvs.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) @@ -543,6 +554,7 @@ signatures_with_people AS ( dvs.signed_by_profile_id, dvs.signed_at, dvs.requested_at, + dvs.electronic_signature_id, dvs.created_at, dvs.updated_at, p.full_name AS signed_by_full_name @@ -570,6 +582,7 @@ SELECT signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at, signed_by_full_name diff --git a/pkg/coredata/electronic_signature_document_type.go b/pkg/coredata/electronic_signature_document_type.go index ff6137ac8..fc97ff11c 100644 --- a/pkg/coredata/electronic_signature_document_type.go +++ b/pkg/coredata/electronic_signature_document_type.go @@ -41,8 +41,6 @@ const ( ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE" ElectronicSignatureDocumentTypeStatementOfApplicability ElectronicSignatureDocumentType = "STATEMENT_OF_APPLICABILITY" ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER" - - ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature." ) var ( @@ -51,28 +49,6 @@ var ( _ encoding.TextUnmarshaler = (*ElectronicSignatureDocumentType)(nil) ) -func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType { - return []ElectronicSignatureDocumentType{ - ElectronicSignatureDocumentTypeNDA, - ElectronicSignatureDocumentTypeDPA, - ElectronicSignatureDocumentTypeMSA, - ElectronicSignatureDocumentTypeSOW, - ElectronicSignatureDocumentTypeSLA, - ElectronicSignatureDocumentTypeTOS, - ElectronicSignatureDocumentTypePrivacyPolicy, - ElectronicSignatureDocumentTypeGovernance, - ElectronicSignatureDocumentTypePolicy, - ElectronicSignatureDocumentTypeProcedure, - ElectronicSignatureDocumentTypePlan, - ElectronicSignatureDocumentTypeRegister, - ElectronicSignatureDocumentTypeRecord, - ElectronicSignatureDocumentTypeReport, - ElectronicSignatureDocumentTypeTemplate, - ElectronicSignatureDocumentTypeStatementOfApplicability, - ElectronicSignatureDocumentTypeOther, - } -} - func (v ElectronicSignatureDocumentType) IsValid() bool { switch v { case @@ -157,51 +133,6 @@ func (dt ElectronicSignatureDocumentType) DisplayName() string { } } -func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) { - var docAgreement string - - switch dt { - case ElectronicSignatureDocumentTypeNDA: - docAgreement = "I agree to the terms of this Non-Disclosure Agreement." - case ElectronicSignatureDocumentTypeDPA: - docAgreement = "I agree to the terms of this Data Processing Agreement." - case ElectronicSignatureDocumentTypeMSA: - docAgreement = "I agree to the terms of this Master Service Agreement." - case ElectronicSignatureDocumentTypeSOW: - docAgreement = "I agree to the terms of this Statement of Work." - case ElectronicSignatureDocumentTypeSLA: - docAgreement = "I agree to the terms of this Service Level Agreement." - case ElectronicSignatureDocumentTypeTOS: - docAgreement = "I agree to these Terms of Service." - case ElectronicSignatureDocumentTypePrivacyPolicy: - docAgreement = "I agree to this Privacy Policy." - case ElectronicSignatureDocumentTypeGovernance: - docAgreement = "I acknowledge and agree to this Governance Document." - case ElectronicSignatureDocumentTypePolicy: - docAgreement = "I acknowledge and agree to this Policy." - case ElectronicSignatureDocumentTypeProcedure: - docAgreement = "I acknowledge and agree to this Procedure." - case ElectronicSignatureDocumentTypePlan: - docAgreement = "I acknowledge and agree to this Plan." - case ElectronicSignatureDocumentTypeRegister: - docAgreement = "I acknowledge and agree to this Register." - case ElectronicSignatureDocumentTypeRecord: - docAgreement = "I acknowledge and agree to this Record." - case ElectronicSignatureDocumentTypeReport: - docAgreement = "I acknowledge and agree to this Report." - case ElectronicSignatureDocumentTypeTemplate: - docAgreement = "I acknowledge and agree to this Template." - case ElectronicSignatureDocumentTypeStatementOfApplicability: - docAgreement = "I acknowledge and agree to this Statement of Applicability." - case ElectronicSignatureDocumentTypeOther: - return "", fmt.Errorf("cannot get consent text: document type OTHER requires explicit consent text") - default: - return "", fmt.Errorf("cannot get consent text: unknown document type %q", dt) - } - - return docAgreement + " " + ESignProcessConsentText, nil -} - func ElectronicSignatureDocumentTypeFromDocumentType(dt DocumentType) ElectronicSignatureDocumentType { switch dt { case DocumentTypeGovernance: diff --git a/pkg/coredata/migrations/20260610T992900Z.sql b/pkg/coredata/migrations/20260610T992900Z.sql new file mode 100644 index 000000000..0ea90fb9a --- /dev/null +++ b/pkg/coredata/migrations/20260610T992900Z.sql @@ -0,0 +1,16 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +ALTER TABLE document_version_signatures + ADD COLUMN electronic_signature_id TEXT REFERENCES electronic_signatures(id) ON DELETE RESTRICT; diff --git a/pkg/esign/service.go b/pkg/esign/service.go index 0619a27e5..1df72e974 100644 --- a/pkg/esign/service.go +++ b/pkg/esign/service.go @@ -19,7 +19,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "go.gearno.de/crypto/uuid" @@ -151,16 +150,7 @@ func (s *Service) CreateSignature( ) (*coredata.ElectronicSignature, error) { consentText := req.ConsentText if consentText == "" { - var err error - - consentText, err = req.DocumentType.ConsentText() - if err != nil { - return nil, fmt.Errorf("cannot derive consent text: %w", err) - } - } else { - if !strings.HasSuffix(consentText, coredata.ESignProcessConsentText) { - consentText = consentText + " " + coredata.ESignProcessConsentText - } + return nil, fmt.Errorf("consent text is required") } emailSubject := req.EmailSubject diff --git a/pkg/probo/document_approval_service.go b/pkg/probo/document_approval_service.go index 6d75ba381..ffab27187 100644 --- a/pkg/probo/document_approval_service.go +++ b/pkg/probo/document_approval_service.go @@ -36,6 +36,8 @@ import ( "go.probo.inc/probo/pkg/statelesstoken" ) +const DocumentApprovalConsentText = "By clicking \"Review and approve\", I consent to approve this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature." + type ( DocumentApprovalService struct { svc *Service @@ -364,7 +366,7 @@ func (s *DocumentApprovalService) Approve( SignerFullName: req.SignerFullName, SignerIPAddr: req.SignerIPAddr, SignerUA: req.SignerUA, - ConsentText: "By clicking Approve, I consent to approve this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature.", + ConsentText: DocumentApprovalConsentText, EmailSubject: fmt.Sprintf("Your approved %s - Certificate of Completion", document.Title), }, ) diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 45047b42c..38c9d6c8a 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -38,6 +38,7 @@ import ( "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/docgen" + "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/iam" @@ -50,6 +51,8 @@ import ( "go.probo.inc/probo/pkg/validator" ) +const DocumentSignatureConsentText = "By clicking \"Review and sign\", I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature." + type ( DocumentService struct { svc *Service @@ -119,6 +122,15 @@ type ( Signatory gid.GID } + SignDocumentVersionRequest struct { + DocumentVersionID gid.GID + IdentityID gid.GID + SignerFullName string + SignerEmail mail.Addr + SignerIPAddr string + SignerUA string + } + BulkRequestSignaturesRequest struct { DocumentIDs []gid.GID SignatoryIDs []gid.GID @@ -842,75 +854,161 @@ func (s *DocumentService) SendSigningNotifications( func (s *DocumentService) SignDocumentVersionByIdentity( ctx context.Context, scope coredata.Scoper, - documentVersionID gid.GID, - identityID gid.GID, + req SignDocumentVersionRequest, ) (*coredata.DocumentVersionSignature, error) { - var documentVersionSignature *coredata.DocumentVersionSignature + var ( + documentVersion *coredata.DocumentVersion + document *coredata.Document + documentVersionSignature *coredata.DocumentVersionSignature + ) - err := s.svc.pg.WithTx( + err := s.svc.pg.WithConn( ctx, - func(ctx context.Context, conn pg.Tx) error { - documentVersion := &coredata.DocumentVersion{} - if err := documentVersion.LoadByID(ctx, conn, scope, documentVersionID); err != nil { + func(ctx context.Context, conn pg.Querier) error { + documentVersion = &coredata.DocumentVersion{} + if err := documentVersion.LoadByID(ctx, conn, scope, req.DocumentVersionID); err != nil { return fmt.Errorf("cannot get document version: %w", err) } + if documentVersion.Status != coredata.DocumentVersionStatusPublished { + return &ErrDocumentVersionNotPublished{} + } + + document = &coredata.Document{} + if err := document.LoadByID(ctx, conn, scope, documentVersion.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } + + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + profile := &coredata.MembershipProfile{} // FIXME: will be done differently - if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, identityID, documentVersion.OrganizationID); err != nil { + if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, req.IdentityID, documentVersion.OrganizationID); err != nil { return fmt.Errorf("cannot find profile record for user email in organization %q: %w", documentVersion.OrganizationID, err) } - var signErr error + documentVersionSignature = &coredata.DocumentVersionSignature{} + if err := documentVersionSignature.LoadByDocumentVersionIDAndSignatory(ctx, conn, scope, req.DocumentVersionID, profile.ID); err != nil { + return fmt.Errorf("cannot load document version signature: %w", err) + } - documentVersionSignature, signErr = s.signDocumentVersionInTx(ctx, scope, conn, documentVersionID, profile.ID) + if documentVersionSignature.State == coredata.DocumentVersionSignatureStateSigned { + return &ErrDocumentVersionSignatureAlreadySigned{} + } - return signErr + return nil }, ) if err != nil { - return nil, fmt.Errorf("cannot sign document version: %w", err) + return nil, err } - return documentVersionSignature, nil -} - -func (s *DocumentService) signDocumentVersionInTx( - ctx context.Context, scope coredata.Scoper, - conn pg.Tx, - documentVersionID gid.GID, - signatory gid.GID, -) (*coredata.DocumentVersionSignature, error) { - documentVersion := &coredata.DocumentVersion{} - documentVersionSignature := &coredata.DocumentVersionSignature{} now := time.Now() - if err := documentVersion.LoadByID(ctx, conn, scope, documentVersionID); err != nil { - return nil, fmt.Errorf("cannot load document version %q: %w", documentVersionID, err) + pdfData, err := s.ExportPDF(ctx, scope, req.DocumentVersionID, ExportPDFOptions{}) + if err != nil { + return nil, fmt.Errorf("cannot export document PDF: %w", err) } - if documentVersion.Status != coredata.DocumentVersionStatusPublished { - return nil, fmt.Errorf("cannot sign unpublished version") + fileRecord := &coredata.File{ + ID: gid.New(scope.GetTenantID(), coredata.FileEntityType), + OrganizationID: documentVersion.OrganizationID, + BucketName: s.svc.bucket, + MimeType: "application/pdf", + FileName: fmt.Sprintf("signature-%s.pdf", documentVersionSignature.ID), + FileKey: uuid.MustNewV4().String(), + Visibility: coredata.FileVisibilityPrivate, + CreatedAt: now, + UpdatedAt: now, } - if err := documentVersionSignature.LoadByDocumentVersionIDAndSignatory(ctx, conn, scope, documentVersionID, signatory); err != nil { - return nil, fmt.Errorf("cannot load document version signature: %w", err) + fileSize, err := s.svc.fileManager.PutFile( + ctx, + fileRecord, + bytes.NewReader(pdfData), + map[string]string{ + "type": "signature-document", + "signature-id": documentVersionSignature.ID.String(), + }, + ) + if err != nil { + return nil, fmt.Errorf("cannot upload signature PDF: %w", err) } - if documentVersionSignature.State == coredata.DocumentVersionSignatureStateSigned { - return nil, &ErrDocumentVersionSignatureAlreadySigned{} - } + fileRecord.FileSize = fileSize - documentVersionSignature.State = coredata.DocumentVersionSignatureStateSigned - documentVersionSignature.SignedAt = &now - documentVersionSignature.UpdatedAt = now + signatureID := documentVersionSignature.ID - if err := documentVersion.Update(ctx, conn, scope); err != nil { - return nil, fmt.Errorf("cannot update document version: %w", err) - } + err = s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + documentVersion = &coredata.DocumentVersion{} + if err := documentVersion.LoadByID(ctx, tx, scope, req.DocumentVersionID); err != nil { + return fmt.Errorf("cannot load document version: %w", err) + } - if err := documentVersionSignature.Update(ctx, conn, scope); err != nil { - return nil, fmt.Errorf("cannot update document version signature: %w", err) + if documentVersion.Status != coredata.DocumentVersionStatusPublished { + return &ErrDocumentVersionNotPublished{} + } + + document = &coredata.Document{} + if err := document.LoadByID(ctx, tx, scope, documentVersion.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } + + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + + documentVersionSignature = &coredata.DocumentVersionSignature{} + if err := documentVersionSignature.LoadByID(ctx, tx, scope, signatureID); err != nil { + return fmt.Errorf("cannot load document version signature: %w", err) + } + + if documentVersionSignature.State == coredata.DocumentVersionSignatureStateSigned { + return &ErrDocumentVersionSignatureAlreadySigned{} + } + + if err := fileRecord.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert signature file record: %w", err) + } + + esig, err := s.svc.esign.CreateAndAcceptSignature( + ctx, + tx, + &esign.CreateAndAcceptSignatureRequest{ + OrganizationID: documentVersion.OrganizationID, + DocumentType: coredata.ElectronicSignatureDocumentTypeFromDocumentType(documentVersion.DocumentType), + DocumentName: &document.Title, + FileID: fileRecord.ID, + SignerEmail: req.SignerEmail, + SignerFullName: req.SignerFullName, + SignerIPAddr: req.SignerIPAddr, + SignerUA: req.SignerUA, + ConsentText: DocumentSignatureConsentText, + EmailSubject: fmt.Sprintf("Your signed %s - Certificate of Completion", document.Title), + }, + ) + if err != nil { + return fmt.Errorf("cannot create electronic signature: %w", err) + } + + documentVersionSignature.State = coredata.DocumentVersionSignatureStateSigned + documentVersionSignature.SignedAt = &now + documentVersionSignature.ElectronicSignatureID = &esig.ID + documentVersionSignature.UpdatedAt = now + + if err := documentVersionSignature.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update document version signature: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err } return documentVersionSignature, nil diff --git a/pkg/server/api/console/v1/document_resolvers.go b/pkg/server/api/console/v1/document_resolvers.go index fba0dcabd..95d743ea7 100644 --- a/pkg/server/api/console/v1/document_resolvers.go +++ b/pkg/server/api/console/v1/document_resolvers.go @@ -427,6 +427,11 @@ func (r *documentVersionApprovalDecisionResolver) Approver(ctx context.Context, return types.NewProfile(profile), nil } +// ConsentText is the resolver for the consentText field. +func (r *documentVersionApprovalDecisionResolver) ConsentText(ctx context.Context, obj *types.DocumentVersionApprovalDecision) (string, error) { + return probo.DocumentApprovalConsentText, nil +} + // Permission is the resolver for the permission field. func (r *documentVersionApprovalDecisionResolver) Permission(ctx context.Context, obj *types.DocumentVersionApprovalDecision, action string) (bool, error) { // Approve and reject actions are only allowed for the viewer's own decision. @@ -788,6 +793,11 @@ func (r *employeeDocumentVersionResolver) Signed(ctx context.Context, obj *types return signed, nil } +// ConsentText is the resolver for the consentText field. +func (r *employeeDocumentVersionResolver) ConsentText(ctx context.Context, obj *types.EmployeeDocumentVersion) (string, error) { + return probo.DocumentSignatureConsentText, nil +} + // ApprovalDecision is the resolver for the approvalDecision field. func (r *employeeDocumentVersionResolver) ApprovalDecision(ctx context.Context, obj *types.EmployeeDocumentVersion) (*types.DocumentVersionApprovalDecision, error) { scope, err := r.authorize(ctx, obj.DocumentID, probo.ActionEmployeeDocumentGet) @@ -1423,9 +1433,38 @@ func (r *mutationResolver) SignDocument(ctx context.Context, input types.SignDoc } identity := authn.IdentityFromContext(ctx) + httpReq := gqlutils.HTTPRequestFromContext(ctx) - documentVersionSignature, err := r.probo.Documents.SignDocumentVersionByIdentity(ctx, scope, input.DocumentVersionID, identity.ID) + signerIP, _, _ := net.SplitHostPort(httpReq.RemoteAddr) + if signerIP == "" { + signerIP = httpReq.RemoteAddr + } + + documentVersionSignature, err := r.probo.Documents.SignDocumentVersionByIdentity( + ctx, + scope, + probo.SignDocumentVersionRequest{ + DocumentVersionID: input.DocumentVersionID, + IdentityID: identity.ID, + SignerFullName: identity.FullName, + SignerEmail: identity.EmailAddress, + SignerIPAddr: signerIP, + SignerUA: httpReq.UserAgent(), + }, + ) if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + + if errNotPublished, ok := errors.AsType[*probo.ErrDocumentVersionNotPublished](err); ok { + return nil, gqlutils.Invalid(ctx, errNotPublished) + } + + if errAlreadySigned, ok := errors.AsType[*probo.ErrDocumentVersionSignatureAlreadySigned](err); ok { + return nil, gqlutils.Conflict(ctx, errAlreadySigned) + } + if errors.Is(err, coredata.ErrResourceAlreadyExists) { return nil, gqlutils.Conflict(ctx, err) } diff --git a/pkg/server/api/console/v1/graphql/document.graphql b/pkg/server/api/console/v1/graphql/document.graphql index 1afcd6827..cd31bc7b8 100644 --- a/pkg/server/api/console/v1/graphql/document.graphql +++ b/pkg/server/api/console/v1/graphql/document.graphql @@ -367,6 +367,7 @@ type DocumentVersionApprovalDecision implements Node { approver: Profile! @goField(forceResolver: true) state: DocumentVersionApprovalDecisionState! comment: String + consentText: String! @goField(forceResolver: true) decidedAt: Datetime createdAt: Datetime! updatedAt: Datetime! @@ -422,6 +423,7 @@ type EmployeeDocumentVersion classification: DocumentClassification! documentType: DocumentType! signed: Boolean! @goField(forceResolver: true) + consentText: String! @goField(forceResolver: true) approvalDecision: DocumentVersionApprovalDecision @goField(forceResolver: true) publishedAt: Datetime createdAt: Datetime! diff --git a/pkg/trust/service.go b/pkg/trust/service.go index c141d25c2..b076d36be 100644 --- a/pkg/trust/service.go +++ b/pkg/trust/service.go @@ -34,6 +34,8 @@ import ( "go.probo.inc/probo/pkg/slack" ) +const NDAConsentText = "By clicking \"Review and sign\", I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature. If you have questions about the NDA, please contact security@probo.com." + type ( Service struct { pg *pg.Client @@ -385,6 +387,7 @@ func (s *Service) ProvisionMember( DocumentType: coredata.ElectronicSignatureDocumentTypeNDA, FileID: *compliancePage.NonDisclosureAgreementFileID, SignerEmail: identity.EmailAddress, + ConsentText: NDAConsentText, }, ) if err != nil {