Add esign to document signatures
Employee document signatures recorded an acknowledgment with no cryptographic proof, unlike document approvals which already create and accept an electronic signature on every decision. Mirror the approval flow on the sign path: generate the signed document PDF, create-and-accept an esign record, and persist its id on the document_version_signatures row through a new electronic_signature_id column. Capture the signer IP and user agent in the resolver, and re-check the published/archived preconditions inside the transaction so the seal cannot race document state. Make the consent wording a single backend source of truth shared by the text that is sealed and the text shown in the UI. Define DocumentSignatureConsentText and DocumentApprovalConsentText in the probo service package and the NDA copy in the trust service, each owned by the flow that uses it, and stop esign from appending the generic clause to caller-provided consent text so approvals no longer seal a duplicated sentence. Expose the resolved consent text through GraphQL on EmployeeDocumentVersion and DocumentVersionApprovalDecision, and have the signing, approval, and NDA pages render it from the API instead of hard-coded strings, mirroring how the NDA page already worked. Align the wording with the actual interaction: the buttons read "Review and sign" and "Review and approve", the clauses reference those actions, and the inaccurate "typing my full name" phrasing is removed everywhere. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -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")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-txt-tertiary">
|
||||
{__("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}
|
||||
</p>
|
||||
<Button onClick={onBack} className="w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
|
||||
@@ -22,6 +22,7 @@ const fragment = graphql`
|
||||
fragment VersionActionsFragment on EmployeeDocumentVersion {
|
||||
id
|
||||
signed
|
||||
consentText
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -59,12 +60,10 @@ export function VersionActions({
|
||||
disabled={isSigning}
|
||||
icon={isSigning ? Spinner : undefined}
|
||||
>
|
||||
{__("I acknowledge and agree")}
|
||||
{__("Review and sign")}
|
||||
</Button>
|
||||
<p className="text-xs text-txt-tertiary mt-2 h-5">
|
||||
{__(
|
||||
"By clicking 'I acknowledge and agree', your digital signature will be recorded.",
|
||||
)}
|
||||
<p className="text-xs text-txt-tertiary mt-2">
|
||||
{versionData.consentText}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -223,11 +223,7 @@ export function NDAPage(props: {
|
||||
return <Navigate to="/overview" replace />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-level-2 flex flex-col min-h-screen lg:h-screen">
|
||||
@@ -297,7 +293,7 @@ export function NDAPage(props: {
|
||||
>
|
||||
{isFailed
|
||||
? __("Try again")
|
||||
: __("Accept")}
|
||||
: __("Review and sign")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
16
pkg/coredata/migrations/20260610T992900Z.sql
Normal file
16
pkg/coredata/migrations/20260610T992900Z.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
--
|
||||
-- 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user