Add electronic signature

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-02-16 23:12:09 +01:00
parent e6f9d7aae2
commit c191d25e9a
58 changed files with 7557 additions and 292 deletions

View File

@@ -1,147 +0,0 @@
import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Button, Card, Field, Logo, Spinner } from "@probo/ui";
import { clsx } from "clsx";
import { use, useEffect } from "react";
import { graphql } from "relay-runtime";
import { useWindowSize } from "usehooks-ts";
import { z } from "zod";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToast";
import { Viewer } from "#/providers/Viewer";
import { PDFPreview } from "./PDFPreview";
const signMutation = graphql`
mutation NDADialogSignMutation($input: AcceptNonDisclosureAgreementInput!) {
acceptNonDisclosureAgreement(input: $input) {
success
}
}
`;
const schema = z.object({
fullName: z.string(),
});
export function NDADialog({
organizationName,
url,
fileName,
}: {
organizationName: string;
url?: string | null;
fileName?: string | null;
}) {
const { __ } = useTranslate();
useEffect(() => {
document.body.style.setProperty("overflow", "hidden");
return () => {
document.body.style.removeProperty("overflow");
};
}, []);
const { width } = useWindowSize();
const isMobile = width < 1100;
const isDesktop = !isMobile;
const viewer = use(Viewer);
const {
handleSubmit: handleSubmitWrapper,
register,
formState,
} = useFormWithSchema(schema, {
defaultValues: {
fullName: viewer?.fullName,
},
});
const [commitSigning, isSigning] = useMutationWithToasts(signMutation, {
onSuccess: () => {
window.location.reload();
},
});
const handleSubmit = handleSubmitWrapper(async ({ fullName }) => {
await commitSigning({
variables: {
input: {
fullName,
},
},
});
});
return (
<div className="fixed inset-0 bg-level-2 z-100 flex flex-col lg:h-screen">
<header className="flex items-center h-12 justify-between border-b border-border-solid px-4 flex-none">
<Logo />
</header>
<div className="grid lg:grid-cols-2 min-h-0 h-full">
<div className="max-w-[440px] mx-auto py-20">
<h1 className="text-2xl font-semibold mb-4">
{__("Review & Sign NDA")}
</h1>
<p className="text-txt-secondary">
{sprintf(
__(
"Access to %s compliance page documents requires signing a Non-Disclosure Agreement (NDA). Please review the agreement below. Once signed, youll receive immediate access to the requested documents.",
),
organizationName,
)}
</p>
{isMobile && url && (
<Card className="flex justify-between py-3 px-4 text-sm items-center my-6">
{fileName}
<Button variant="secondary" asChild>
<a target="_blank" rel="noopener noreferrer" href={url}>
{__("View document")}
</a>
</Button>
</Card>
)}
<form onSubmit={e => void handleSubmit(e)}>
<div className="mt-4">
<Field
required
label={__("Full name")}
placeholder="John Doe"
{...register("fullName")}
type="text"
/>
<Button
type="submit"
className="h-10 w-full my-8"
disabled={formState.isSubmitting || !formState.isValid}
icon={isSigning ? Spinner : undefined}
>
{__("Review & Sign")}
</Button>
</div>
</form>
<p className="text-xs text-txt-secondary">
{__(
"By clicking Review & Sign, you agree to the terms of this NDA. If you have questions about the NDA, please contact security@probo.com.",
)}
</p>
<a
href="https://www.getprobo.com/"
className={clsx(
"flex gap-1 text-sm font-medium text-txt-tertiary items-center w-max mx-auto",
isMobile ? "mt-15" : "mt-30",
)}
>
Powered by
{" "}
<Logo withPicto className="h-6" />
</a>
</div>
{isDesktop && (
<div className="bg-subtle h-full border-l border-border-solid min-h-0">
{url && <PDFPreview src={url} name={fileName ?? ""} />}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,92 +0,0 @@
/**
* @generated SignedSource<<22d97ce5cead5a1cc6c23e89fca72cb1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type AcceptNonDisclosureAgreementInput = {
fullName: string;
};
export type NDADialogSignMutation$variables = {
input: AcceptNonDisclosureAgreementInput;
};
export type NDADialogSignMutation$data = {
readonly acceptNonDisclosureAgreement: {
readonly success: boolean;
} | null | undefined;
};
export type NDADialogSignMutation = {
response: NDADialogSignMutation$data;
variables: NDADialogSignMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "AcceptNonDisclosureAgreementPayload",
"kind": "LinkedField",
"name": "acceptNonDisclosureAgreement",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "NDADialogSignMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "NDADialogSignMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "130cfc307dca0525194e0103a0548bd0",
"id": null,
"metadata": {},
"name": "NDADialogSignMutation",
"operationKind": "mutation",
"text": "mutation NDADialogSignMutation(\n $input: AcceptNonDisclosureAgreementInput!\n) {\n acceptNonDisclosureAgreement(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "1b9447e5cbb2ec7dce4f3f9c68493555";
export default node;

View File

@@ -2,9 +2,8 @@ import { useFavicon, useSystemTheme } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Logo, TabLink, Tabs } from "@probo/ui";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { Outlet } from "react-router";
import { Navigate, Outlet } from "react-router";
import { NDADialog } from "#/components/NDADialog";
import { OrganizationSidebar } from "#/components/OrganizationSidebar";
import { TrustCenterProvider } from "#/providers/TrustCenterProvider";
import { Viewer } from "#/providers/Viewer";
@@ -28,20 +27,20 @@ export function MainLayout(props: Props) {
return null;
}
const showNDADialog
const ndaSig = trustCenter.ndaSignature;
const hasPendingNDA
= trustCenter.isViewerMember
&& !trustCenter.hasAcceptedNonDisclosureAgreement
&& trustCenter.ndaFileUrl;
&& trustCenter.ndaFileUrl
&& ndaSig
&& ndaSig.status !== "COMPLETED";
if (hasPendingNDA) {
return <Navigate to="/nda" replace />;
}
return (
<Viewer value={data.viewer}>
<TrustCenterProvider trustCenter={trustCenter}>
{showNDADialog && (
<NDADialog
organizationName={trustCenter.organization.name}
url={trustCenter.ndaFileUrl}
fileName={trustCenter.ndaFileName}
/>
)}
<div className="grid grid-cols-1 max-w-[1280px] mx-4 pt-6 gap-4 lg:mx-auto lg:gap-10 lg:pt-20 lg:grid-cols-[400px_1fr] lg:items-start ">
<OrganizationSidebar trustCenter={trustCenter} />
<main>

View File

@@ -0,0 +1,332 @@
import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Button, Card, Field, IconCircleX, Logo, Spinner } from "@probo/ui";
import { startTransition, useEffect, useRef } from "react";
import {
type PreloadedQuery,
useMutation,
usePreloadedQuery,
useRefetchableFragment,
} from "react-relay";
import { Navigate, useNavigate } from "react-router";
import { graphql } from "relay-runtime";
import { useWindowSize } from "usehooks-ts";
import { z } from "zod";
import { PDFPreview } from "#/components/PDFPreview";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import type { NDAPageAcceptElectronicSignatureMutation } from "./__generated__/NDAPageAcceptElectronicSignatureMutation.graphql";
import type { NDAPageFragment$key } from "./__generated__/NDAPageFragment.graphql";
import type { NDAPageQuery as NDAPageQueryType } from "./__generated__/NDAPageQuery.graphql";
import type { NDAPageRecordSigningEventMutation } from "./__generated__/NDAPageRecordSigningEventMutation.graphql";
import type { NDAPageRefetchQuery } from "./__generated__/NDAPageRefetchQuery.graphql";
export const ndaPageQuery = graphql`
query NDAPageQuery {
viewer {
fullName
}
currentTrustCenter @required(action: THROW) {
organization {
name
}
ndaFileUrl
ndaFileName
ndaSignature {
status
}
...NDAPageFragment
}
}
`;
const ndaPageFragment = graphql`
fragment NDAPageFragment on TrustCenter
@refetchable(queryName: "NDAPageRefetchQuery") {
ndaSignature @required(action: THROW) {
id
status
consentText
lastError
}
}
`;
const acceptElectronicSignatureMutation = graphql`
mutation NDAPageAcceptElectronicSignatureMutation(
$input: AcceptElectronicSignatureInput!
) {
acceptElectronicSignature(input: $input) {
signature {
id
status
}
}
}
`;
const recordSigningEventMutation = graphql`
mutation NDAPageRecordSigningEventMutation(
$input: RecordSigningEventInput!
) {
recordSigningEvent(input: $input) {
success
}
}
`;
const schema = z.object({
fullName: z.string().min(1),
});
export function NDAPage(props: {
queryRef: PreloadedQuery<NDAPageQueryType>;
}) {
const { __ } = useTranslate();
const navigate = useNavigate();
const documentViewedRef = useRef(false);
const queryData = usePreloadedQuery(ndaPageQuery, props.queryRef);
const trustCenter = queryData.currentTrustCenter;
const viewer = queryData.viewer;
const [data, refetch] = useRefetchableFragment<NDAPageRefetchQuery, NDAPageFragment$key>(
ndaPageFragment,
trustCenter,
);
const ndaSignature = data.ndaSignature;
const { width } = useWindowSize();
const isMobile = width < 1100;
const isDesktop = !isMobile;
const {
handleSubmit: handleSubmitWrapper,
register,
formState,
} = useFormWithSchema(schema, {
defaultValues: {
fullName: viewer?.fullName,
},
});
const [acceptSignature, isAccepting] = useMutation<NDAPageAcceptElectronicSignatureMutation>(
acceptElectronicSignatureMutation,
);
const [recordSigningEvent] = useMutation<NDAPageRecordSigningEventMutation>(
recordSigningEventMutation,
);
const isProcessing
= ndaSignature.status === "ACCEPTED"
|| ndaSignature.status === "PROCESSING";
const isFailed = ndaSignature.status === "FAILED";
const isCompleted = ndaSignature.status === "COMPLETED";
useEffect(() => {
if (isCompleted) {
navigate("/overview", { replace: true });
}
}, [isCompleted, navigate]);
useEffect(() => {
if (!isProcessing) return;
const poll = () => startTransition(() => {
refetch({}, { fetchPolicy: "network-only" });
});
const interval = setInterval(poll, 1500);
return () => clearInterval(interval);
}, [isProcessing, refetch]);
useEffect(() => {
if (
ndaSignature
&& ndaSignature.status === "PENDING"
&& !documentViewedRef.current
) {
documentViewedRef.current = true;
recordSigningEvent({
variables: {
input: {
signatureId: ndaSignature.id,
eventType: "DOCUMENT_VIEWED",
},
},
});
}
}, [ndaSignature, recordSigningEvent]);
const handleSubmit = handleSubmitWrapper(({ fullName }) => {
if (!ndaSignature) return;
if (ndaSignature.status === "PENDING") {
recordSigningEvent({
variables: {
input: {
signatureId: ndaSignature.id,
eventType: "FULL_NAME_TYPED",
},
},
});
}
recordSigningEvent({
variables: {
input: {
signatureId: ndaSignature.id,
eventType: "CONSENT_GIVEN",
},
},
onCompleted: () => {
acceptSignature({
variables: {
input: {
signatureId: ndaSignature.id,
fullName,
},
},
});
},
});
});
const handleTryAgain = () => {
if (ndaSignature && viewer?.fullName) {
acceptSignature({
variables: {
input: {
signatureId: ndaSignature.id,
fullName: viewer.fullName,
},
},
});
}
};
if (
!trustCenter.ndaFileUrl
|| !trustCenter.ndaSignature
|| trustCenter.ndaSignature.status === "COMPLETED"
) {
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.",
);
return (
<div className="bg-level-2 flex flex-col min-h-screen lg:h-screen">
<header className="flex items-center h-12 justify-between border-b border-border-solid px-4 flex-none">
<Logo />
</header>
<div className="grid lg:grid-cols-2 min-h-0 flex-1">
<div className="flex flex-col items-center overflow-y-auto">
<div className="max-w-[440px] w-full mx-auto px-4 py-12 lg:py-20 flex-1">
<h1 className="text-2xl font-semibold">
{__("Non-Disclosure Agreement")}
</h1>
<p className="text-txt-secondary mt-2">
{sprintf(
__(
"%s requires you to sign an NDA before accessing compliance documents.",
),
trustCenter.organization.name,
)}
</p>
{isMobile && trustCenter.ndaFileUrl && (
<Card className="flex justify-between py-3 px-4 text-sm items-center mt-6">
{trustCenter.ndaFileName}
<Button variant="secondary" asChild>
<a target="_blank" rel="noopener noreferrer" href={trustCenter.ndaFileUrl}>
{__("View document")}
</a>
</Button>
</Card>
)}
<form
onSubmit={
isFailed
? (e) => {
e.preventDefault();
handleTryAgain();
}
: e => void handleSubmit(e)
}
className="mt-8"
>
<Field
required
label={__("Full name")}
placeholder="John Doe"
{...register("fullName")}
type="text"
disabled={isProcessing}
/>
<p className="text-xs text-txt-tertiary mt-6">
{consentText}
</p>
{isFailed && (
<div className="flex items-start gap-2 mt-4 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-800">
<IconCircleX size={16} className="mt-0.5 shrink-0" />
<div>
<p className="font-medium">
{__("Signature processing failed")}
</p>
<p className="mt-0.5 text-red-600">
{ndaSignature?.lastError
?? __("We encountered an issue processing your signature. Please try again.")}
</p>
</div>
</div>
)}
{isProcessing
? (
<Button
type="button"
className="h-10 w-full mt-4"
disabled
icon={Spinner}
>
{__("Sealing your signature...")}
</Button>
)
: (
<Button
type="submit"
className="h-10 w-full mt-4"
disabled={formState.isSubmitting || !formState.isValid}
icon={isAccepting ? Spinner : undefined}
>
{isFailed
? __("Try again")
: __("Accept")}
</Button>
)}
</form>
</div>
<a
href="https://www.getprobo.com/"
className="flex gap-1 text-sm font-medium text-txt-tertiary items-center py-6"
>
Powered by
{" "}
<Logo withPicto className="h-6" />
</a>
</div>
{isDesktop && (
<div className="bg-subtle h-full border-l border-border-solid min-h-0">
{trustCenter.ndaFileUrl && <PDFPreview src={trustCenter.ndaFileUrl} name={trustCenter.ndaFileName ?? ""} />}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { RelayProvider } from "#/providers/RelayProviders";
import type { NDAPageQuery } from "./__generated__/NDAPageQuery.graphql";
import { NDAPage, ndaPageQuery } from "./NDAPage";
function NDAPageQueryLoader() {
const [queryRef, loadQuery]
= useQueryLoader<NDAPageQuery>(ndaPageQuery);
useEffect(() => {
if (!queryRef) {
loadQuery({});
}
});
if (!queryRef) return null;
return (
<Suspense>
<NDAPage queryRef={queryRef} />
</Suspense>
);
}
export default function NDAPageLoader() {
return (
<RelayProvider>
<NDAPageQueryLoader />
</RelayProvider>
);
}

View File

@@ -0,0 +1,115 @@
/**
* @generated SignedSource<<41e415a03c63efbb590b1c2c782d5eac>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ElectronicSignatureStatus = "ACCEPTED" | "COMPLETED" | "FAILED" | "PENDING" | "PROCESSING";
export type AcceptElectronicSignatureInput = {
fullName: string;
signatureId: string;
};
export type NDAPageAcceptElectronicSignatureMutation$variables = {
input: AcceptElectronicSignatureInput;
};
export type NDAPageAcceptElectronicSignatureMutation$data = {
readonly acceptElectronicSignature: {
readonly signature: {
readonly id: string;
readonly status: ElectronicSignatureStatus;
};
} | null | undefined;
};
export type NDAPageAcceptElectronicSignatureMutation = {
response: NDAPageAcceptElectronicSignatureMutation$data;
variables: NDAPageAcceptElectronicSignatureMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "AcceptElectronicSignaturePayload",
"kind": "LinkedField",
"name": "acceptElectronicSignature",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ElectronicSignature",
"kind": "LinkedField",
"name": "signature",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "NDAPageAcceptElectronicSignatureMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "NDAPageAcceptElectronicSignatureMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "db0c219a7133025b56252836d7f1a85d",
"id": null,
"metadata": {},
"name": "NDAPageAcceptElectronicSignatureMutation",
"operationKind": "mutation",
"text": "mutation NDAPageAcceptElectronicSignatureMutation(\n $input: AcceptElectronicSignatureInput!\n) {\n acceptElectronicSignature(input: $input) {\n signature {\n id\n status\n }\n }\n}\n"
}
};
})();
(node as any).hash = "b4ab543bd58878bb4968de6ea8b7c448";
export default node;

View File

@@ -0,0 +1,103 @@
/**
* @generated SignedSource<<93f8676826dfa28c1681da68a6ac1cf2>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type ElectronicSignatureStatus = "ACCEPTED" | "COMPLETED" | "FAILED" | "PENDING" | "PROCESSING";
import { FragmentRefs } from "relay-runtime";
export type NDAPageFragment$data = {
readonly id: string;
readonly ndaSignature: {
readonly consentText: string;
readonly id: string;
readonly lastError: string | null | undefined;
readonly status: ElectronicSignatureStatus;
};
readonly " $fragmentType": "NDAPageFragment";
};
export type NDAPageFragment$key = {
readonly " $data"?: NDAPageFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"NDAPageFragment">;
};
import NDAPageRefetchQuery_graphql from './NDAPageRefetchQuery.graphql';
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": {
"refetch": {
"connection": null,
"fragmentPathInResult": [
"node"
],
"operation": NDAPageRefetchQuery_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "NDAPageFragment",
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "ElectronicSignature",
"kind": "LinkedField",
"name": "ndaSignature",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "consentText",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastError",
"storageKey": null
}
],
"storageKey": null
},
"action": "THROW"
},
(v0/*: any*/)
],
"type": "TrustCenter",
"abstractKey": null
};
})();
(node as any).hash = "b53a5f455508ed45124b7d09cfb573da";
export default node;

View File

@@ -0,0 +1,236 @@
/**
* @generated SignedSource<<f1cae8462501cdf634eb02eb143c3806>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ElectronicSignatureStatus = "ACCEPTED" | "COMPLETED" | "FAILED" | "PENDING" | "PROCESSING";
export type NDAPageQuery$variables = Record<PropertyKey, never>;
export type NDAPageQuery$data = {
readonly currentTrustCenter: {
readonly ndaFileName: string | null | undefined;
readonly ndaFileUrl: string | null | undefined;
readonly ndaSignature: {
readonly status: ElectronicSignatureStatus;
} | null | undefined;
readonly organization: {
readonly name: string;
};
readonly " $fragmentSpreads": FragmentRefs<"NDAPageFragment">;
};
readonly viewer: {
readonly fullName: string;
} | null | undefined;
};
export type NDAPageQuery = {
response: NDAPageQuery$data;
variables: NDAPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "ndaFileUrl",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "ndaFileName",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "NDAPageQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v0/*: any*/)
],
"storageKey": null
},
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "TrustCenter",
"kind": "LinkedField",
"name": "currentTrustCenter",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v1/*: any*/)
],
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "ElectronicSignature",
"kind": "LinkedField",
"name": "ndaSignature",
"plural": false,
"selections": [
(v4/*: any*/)
],
"storageKey": null
},
{
"args": null,
"kind": "FragmentSpread",
"name": "NDAPageFragment"
}
],
"storageKey": null
},
"action": "THROW"
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "NDAPageQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v0/*: any*/),
(v5/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "TrustCenter",
"kind": "LinkedField",
"name": "currentTrustCenter",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v1/*: any*/),
(v5/*: any*/)
],
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "ElectronicSignature",
"kind": "LinkedField",
"name": "ndaSignature",
"plural": false,
"selections": [
(v4/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "consentText",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastError",
"storageKey": null
}
],
"storageKey": null
},
(v5/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "c0b175c435c5730e390fcfde596d8bc3",
"id": null,
"metadata": {},
"name": "NDAPageQuery",
"operationKind": "query",
"text": "query NDAPageQuery {\n viewer {\n fullName\n id\n }\n currentTrustCenter {\n organization {\n name\n id\n }\n ndaFileUrl\n ndaFileName\n ndaSignature {\n status\n id\n }\n ...NDAPageFragment\n id\n }\n}\n\nfragment NDAPageFragment on TrustCenter {\n ndaSignature {\n id\n status\n consentText\n lastError\n }\n id\n}\n"
}
};
})();
(node as any).hash = "a78bf8b65feba4627ecb265ca882f645";
export default node;

View File

@@ -0,0 +1,94 @@
/**
* @generated SignedSource<<d2d531924e3667658020b431850c193e>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ElectronicSignatureEventType = "CERTIFICATE_GENERATED" | "CONSENT_GIVEN" | "DOCUMENT_VIEWED" | "FULL_NAME_TYPED" | "SEAL_COMPUTED" | "SIGNATURE_ACCEPTED" | "SIGNATURE_COMPLETED" | "TIMESTAMP_REQUESTED";
export type RecordSigningEventInput = {
eventType: ElectronicSignatureEventType;
signatureId: string;
};
export type NDAPageRecordSigningEventMutation$variables = {
input: RecordSigningEventInput;
};
export type NDAPageRecordSigningEventMutation$data = {
readonly recordSigningEvent: {
readonly success: boolean;
} | null | undefined;
};
export type NDAPageRecordSigningEventMutation = {
response: NDAPageRecordSigningEventMutation$data;
variables: NDAPageRecordSigningEventMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "RecordSigningEventPayload",
"kind": "LinkedField",
"name": "recordSigningEvent",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "NDAPageRecordSigningEventMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "NDAPageRecordSigningEventMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "72aa3c3ec642d5bbb317a6e2a65703de",
"id": null,
"metadata": {},
"name": "NDAPageRecordSigningEventMutation",
"operationKind": "mutation",
"text": "mutation NDAPageRecordSigningEventMutation(\n $input: RecordSigningEventInput!\n) {\n recordSigningEvent(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "ec40eb438c6d6b20f31974e099535887";
export default node;

View File

@@ -0,0 +1,155 @@
/**
* @generated SignedSource<<47d15b007fc81de3850393c9b158726c>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type NDAPageRefetchQuery$variables = {
id: string;
};
export type NDAPageRefetchQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"NDAPageFragment">;
};
};
export type NDAPageRefetchQuery = {
response: NDAPageRefetchQuery$data;
variables: NDAPageRefetchQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "NDAPageRefetchQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "NDAPageFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "NDAPageRefetchQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ElectronicSignature",
"kind": "LinkedField",
"name": "ndaSignature",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "consentText",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastError",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "TrustCenter",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "1c61baa555ad2b24794ce0bf5ecc14ac",
"id": null,
"metadata": {},
"name": "NDAPageRefetchQuery",
"operationKind": "query",
"text": "query NDAPageRefetchQuery(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...NDAPageFragment\n id\n }\n}\n\nfragment NDAPageFragment on TrustCenter {\n ndaSignature {\n id\n status\n consentText\n lastError\n }\n id\n}\n"
}
};
})();
(node as any).hash = "b53a5f455508ed45124b7d09cfb573da";
export default node;

View File

@@ -11,11 +11,13 @@ export const currentTrustGraphQuery = graphql`
id
slug
isViewerMember
hasAcceptedNonDisclosureAgreement
logoFileUrl
darkLogoFileUrl
ndaFileName
ndaFileUrl
ndaSignature {
status
}
organization {
name
description

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<006540288cfca33589264e1f596e1028>>
* @generated SignedSource<<4bf04c5599ef88c6104919907fb182dd>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,6 +10,7 @@
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ElectronicSignatureStatus = "ACCEPTED" | "COMPLETED" | "FAILED" | "PENDING" | "PROCESSING";
export type TrustGraphCurrentQuery$variables = Record<PropertyKey, never>;
export type TrustGraphCurrentQuery$data = {
readonly currentTrustCenter: {
@@ -22,12 +23,14 @@ export type TrustGraphCurrentQuery$data = {
}>;
};
readonly darkLogoFileUrl: string | null | undefined;
readonly hasAcceptedNonDisclosureAgreement: boolean;
readonly id: string;
readonly isViewerMember: boolean;
readonly logoFileUrl: string | null | undefined;
readonly ndaFileName: string | null | undefined;
readonly ndaFileUrl: string | null | undefined;
readonly ndaSignature: {
readonly status: ElectronicSignatureStatus;
} | null | undefined;
readonly organization: {
readonly description: string | null | undefined;
readonly email: string | null | undefined;
@@ -91,35 +94,35 @@ v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasAcceptedNonDisclosureAgreement",
"name": "logoFileUrl",
"storageKey": null
},
v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoFileUrl",
"name": "darkLogoFileUrl",
"storageKey": null
},
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "darkLogoFileUrl",
"name": "ndaFileName",
"storageKey": null
},
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "ndaFileName",
"name": "ndaFileUrl",
"storageKey": null
},
v9 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "ndaFileUrl",
"name": "status",
"storageKey": null
},
v10 = {
@@ -237,7 +240,18 @@ return {
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "ElectronicSignature",
"kind": "LinkedField",
"name": "ndaSignature",
"plural": false,
"selections": [
(v9/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -341,7 +355,19 @@ return {
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "ElectronicSignature",
"kind": "LinkedField",
"name": "ndaSignature",
"plural": false,
"selections": [
(v9/*: any*/),
(v2/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -641,16 +667,16 @@ return {
]
},
"params": {
"cacheID": "af3118fae0bbc255713aae845ab83032",
"cacheID": "5756e245b9ad1dc00d25c505e4764a62",
"id": null,
"metadata": {},
"name": "TrustGraphCurrentQuery",
"operationKind": "query",
"text": "query TrustGraphCurrentQuery {\n viewer {\n email\n fullName\n id\n }\n currentTrustCenter {\n id\n slug\n isViewerMember\n hasAcceptedNonDisclosureAgreement\n logoFileUrl\n darkLogoFileUrl\n ndaFileName\n ndaFileUrl\n organization {\n name\n description\n websiteUrl\n email\n headquarterAddress\n id\n }\n ...OverviewPageFragment\n vendorInfo: vendors(first: 0) {\n totalCount\n }\n audits(first: 50) {\n edges {\n node {\n id\n ...AuditRowFragment\n }\n }\n }\n }\n}\n\nfragment AuditRowFragment on Audit {\n report {\n id\n filename\n isUserAuthorized\n hasUserRequestedAccess\n }\n framework {\n id\n name\n lightLogoURL\n darkLogoURL\n }\n}\n\nfragment DocumentRowFragment on Document {\n id\n title\n isUserAuthorized\n hasUserRequestedAccess\n}\n\nfragment OverviewPageFragment on TrustCenter {\n references(first: 14) {\n edges {\n node {\n id\n name\n logoUrl\n websiteUrl\n }\n }\n }\n vendors(first: 3) {\n edges {\n node {\n id\n countries\n ...VendorRowFragment\n }\n }\n }\n documents(first: 5) {\n edges {\n node {\n id\n ...DocumentRowFragment\n documentType\n }\n }\n }\n trustCenterFiles(first: 5) {\n edges {\n node {\n id\n category\n ...TrustCenterFileRowFragment\n }\n }\n }\n}\n\nfragment TrustCenterFileRowFragment on TrustCenterFile {\n id\n name\n isUserAuthorized\n hasUserRequestedAccess\n}\n\nfragment VendorRowFragment on Vendor {\n name\n description\n websiteUrl\n countries\n}\n"
"text": "query TrustGraphCurrentQuery {\n viewer {\n email\n fullName\n id\n }\n currentTrustCenter {\n id\n slug\n isViewerMember\n logoFileUrl\n darkLogoFileUrl\n ndaFileName\n ndaFileUrl\n ndaSignature {\n status\n id\n }\n organization {\n name\n description\n websiteUrl\n email\n headquarterAddress\n id\n }\n ...OverviewPageFragment\n vendorInfo: vendors(first: 0) {\n totalCount\n }\n audits(first: 50) {\n edges {\n node {\n id\n ...AuditRowFragment\n }\n }\n }\n }\n}\n\nfragment AuditRowFragment on Audit {\n report {\n id\n filename\n isUserAuthorized\n hasUserRequestedAccess\n }\n framework {\n id\n name\n lightLogoURL\n darkLogoURL\n }\n}\n\nfragment DocumentRowFragment on Document {\n id\n title\n isUserAuthorized\n hasUserRequestedAccess\n}\n\nfragment OverviewPageFragment on TrustCenter {\n references(first: 14) {\n edges {\n node {\n id\n name\n logoUrl\n websiteUrl\n }\n }\n }\n vendors(first: 3) {\n edges {\n node {\n id\n countries\n ...VendorRowFragment\n }\n }\n }\n documents(first: 5) {\n edges {\n node {\n id\n ...DocumentRowFragment\n documentType\n }\n }\n }\n trustCenterFiles(first: 5) {\n edges {\n node {\n id\n category\n ...TrustCenterFileRowFragment\n }\n }\n }\n}\n\nfragment TrustCenterFileRowFragment on TrustCenterFile {\n id\n name\n isUserAuthorized\n hasUserRequestedAccess\n}\n\nfragment VendorRowFragment on Vendor {\n name\n description\n websiteUrl\n countries\n}\n"
}
};
})();
(node as any).hash = "566fdb9af999e51fdf50e2eccca60941";
(node as any).hash = "3b8d99e97c59c613f0ef2f01951a5221";
export default node;

View File

@@ -56,6 +56,11 @@ const routes = [
Component: Fragment,
ErrorBoundary: ErrorBoundary,
},
{
path: "/nda",
Component: lazy(() => import("#/pages/NDAPageLoader")),
ErrorBoundary: ErrorBoundary,
},
// Custom domain routes (subdomain-based)
{
path: "/overview",

6
go.mod
View File

@@ -13,9 +13,11 @@ require (
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/crewjam/saml v0.5.1
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.8.0
github.com/jhillyerd/enmime v1.3.0
github.com/modelcontextprotocol/go-sdk v1.2.0
@@ -29,7 +31,7 @@ require (
github.com/yuin/goldmark v1.7.16
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712
go.gearno.de/kit v0.1.1
go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.probo.inc/mcpgen v0.0.0-20251124210642-41a5174eb92f
@@ -66,6 +68,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/di-wu/parser v0.3.0 // indirect
github.com/di-wu/xsd-datetime v1.0.0 // indirect
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
github.com/dnephin/pflag v1.0.7 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -82,7 +85,6 @@ require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.9 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect

8
go.sum
View File

@@ -77,6 +77,10 @@ github.com/di-wu/parser v0.3.0 h1:NMOvy5ifswgt4gsdhySVcKOQtvjC43cHZIfViWctqQY=
github.com/di-wu/parser v0.3.0/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo=
github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI=
github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww=
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ=
@@ -255,8 +259,8 @@ go.gearno.de/kit v0.1.1 h1:QuBZCZ/h2Eyh6DjjR6CGjkdsab/ztHz6xUiIk0FeREE=
go.gearno.de/kit v0.1.1/go.mod h1:WI/gQ14O9M6wsKa/HFL4ZH+Q/U0930hYWZRdHHo9Agk=
go.gearno.de/x/panicf v0.1.1 h1:E3Cr9NB8Ry2EsvEG/1eHr7kplP3tEjTf5d56dTX64VQ=
go.gearno.de/x/panicf v0.1.1/go.mod h1:VnB8oF0UefMZcYeD4v+Wk4U5Z1uza7PHLlhT2CbNEbU=
go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c h1:b8Wwr2owaB6g38tptSk5tekXFSE7RpXsUpaoCO8tQDM=
go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c/go.mod h1:k3GtgnI5X9dl8FlqaNYkCkil7/iACQdFOromU/H4u6I=
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c h1:rIVWwnNxHYu9aZhHkptXlNYTBJbY4ccaIAYjztVeaDc=
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c/go.mod h1:k3GtgnI5X9dl8FlqaNYkCkil7/iACQdFOromU/H4u6I=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=

View File

@@ -217,6 +217,7 @@ const (
subjectTrustCenterAccess = "Compliance Page Access Invitation - %s"
subjectTrustCenterDocumentAccessRejected = "Compliance Page Document Access Rejected - %s"
subjectMagicLink = "Connect to %s"
subjectElectronicSignatureCertificate = "Your signed %s - Certificate of Completion"
)
var (
@@ -238,6 +239,8 @@ var (
trustCenterDocumentAccessRejectedTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-document-access-rejected.txt.tmpl"))
magicLinkHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/magic-link.html.tmpl"))
magicLinkTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/magic-link.txt.tmpl"))
electronicSignatureCertificateHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/electronic-signature-certificate.html.tmpl"))
electronicSignatureCertificateTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/electronic-signature-certificate.txt.tmpl"))
)
func (p *Presenter) getCommonVariables(ctx context.Context) (*CommonVariables, error) {
@@ -460,6 +463,26 @@ func (p *Presenter) RenderMagicLink(ctx context.Context, magicLinkUrlPath string
return fmt.Sprintf(subjectMagicLink, organizationName), textBody, htmlBody, err
}
func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, signerName string, documentType string) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables(ctx)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
}
data := struct {
*CommonVariables
SignerName string
DocumentType string
}{
CommonVariables: vars,
SignerName: signerName,
DocumentType: documentType,
}
textBody, htmlBody, err = renderEmail(electronicSignatureCertificateTextTemplate, electronicSignatureCertificateHTMLTemplate, data)
return fmt.Sprintf(subjectElectronicSignatureCertificate, documentType), textBody, htmlBody, err
}
func renderEmail(textTemplate *texttemplate.Template, htmlTemplate *htmltemplate.Template, data any) (textBody string, htmlBody *string, err error) {
var textBuf bytes.Buffer
if err := textTemplate.Execute(&textBuf, data); err != nil {

View File

@@ -12,6 +12,7 @@ import Invitation from "../src/Invitation";
import PasswordReset from "../src/PasswordReset";
import TrustCenterAccess from "../src/TrustCenterAccess";
import TrustCenterDocumentAccessRejected from "../src/TrustCenterDocumentAccessRejected";
import ElectronicSignatureCertificate from "../src/ElectronicSignatureCertificate";
import MagicLink from "../src/MagicLink";
const __filename = fileURLToPath(import.meta.url);
@@ -59,6 +60,10 @@ const templates: TemplateConfig[] = [
name: "magic-link",
render: () => MagicLink(),
},
{
name: "electronic-signature-certificate",
render: () => ElectronicSignatureCertificate(),
},
];
async function build() {

View File

@@ -0,0 +1,31 @@
import { Text } from "@react-email/components";
import * as React from "react";
import EmailLayout, {
bodyText,
footerText,
} from "./components/EmailLayout";
export const ElectronicSignatureCertificate = () => {
return (
<EmailLayout
subject={`Your signed ${"{{.DocumentType}}"} — Certificate of Completion`}
>
<Text style={bodyText}>
Your <strong>{"{{.DocumentType}}"}</strong> has been signed
electronically. A Certificate of Completion is attached to this email
as a PDF document.
</Text>
<Text style={bodyText}>
The certificate contains a complete record of the signing event,
including the integrity seal, timestamp, and full audit trail.
</Text>
<Text style={footerText}>
If you have any questions, please contact support.
</Text>
</EmailLayout>
);
};
export default ElectronicSignatureCertificate;

View File

@@ -0,0 +1,12 @@
{{.SenderCompanyName}}
Hi {{.RecipientFullName}},
Your {{.DocumentType}} has been signed electronically. A Certificate of Completion is attached to this email as a PDF document.
The certificate contains a complete record of the signing event, including the integrity seal, timestamp, and audit trail.
If you have any questions, please contact support.
{{.SenderCompanyHeadquarterAddress}}
Powered By Probo

View File

@@ -0,0 +1,424 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/crypto/hash"
"go.probo.inc/probo/pkg/gid"
)
type ElectronicSignature struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
OrganizationID gid.GID `db:"organization_id"`
Status ElectronicSignatureStatus `db:"status"`
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
FileID gid.GID `db:"file_id"`
SignerEmail string `db:"signer_email"`
ConsentText string `db:"consent_text"`
SignerFullName *string `db:"signer_full_name"`
SignerIPAddress *string `db:"signer_ip_address"`
SignerUserAgent *string `db:"signer_user_agent"`
FileHash *string `db:"file_hash"`
Seal *string `db:"seal"`
SealVersion int `db:"seal_version"`
TSAToken []byte `db:"tsa_token"`
SignedAt *time.Time `db:"signed_at"`
CertificateFileID *gid.GID `db:"certificate_file_id"`
CertificateProcessingStartedAt *time.Time `db:"certificate_processing_started_at"`
AttemptCount int `db:"attempt_count"`
MaxAttempts int `db:"max_attempts"`
LastAttemptedAt *time.Time `db:"last_attempted_at"`
LastError *string `db:"last_error"`
ProcessingStartedAt *time.Time `db:"processing_started_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (es *ElectronicSignature) NewEvent(
eventType ElectronicSignatureEventType,
eventSource ElectronicSignatureEventSource,
) ElectronicSignatureEvent {
now := time.Now()
return ElectronicSignatureEvent{
ID: gid.New(es.ID.TenantID(), ElectronicSignatureEventEntityType),
ElectronicSignatureID: es.ID,
EventType: eventType,
EventSource: eventSource,
ActorEmail: es.SignerEmail,
ActorIPAddress: ref.UnrefOrZero(es.SignerIPAddress),
ActorUserAgent: ref.UnrefOrZero(es.SignerUserAgent),
OccurredAt: now,
CreatedAt: now,
}
}
func (es *ElectronicSignature) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO electronic_signatures (
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, seal_version, attempt_count, max_attempts,
created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @status, @document_type, @file_id,
@signer_email, @consent_text, @seal_version, @attempt_count, @max_attempts,
@created_at, @updated_at
)
`
args := pgx.StrictNamedArgs{
"id": es.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": es.OrganizationID,
"status": es.Status,
"document_type": es.DocumentType,
"file_id": es.FileID,
"signer_email": es.SignerEmail,
"consent_text": es.ConsentText,
"seal_version": es.SealVersion,
"attempt_count": es.AttemptCount,
"max_attempts": es.MaxAttempts,
"created_at": es.CreatedAt,
"updated_at": es.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert electronic signature: %w", err)
}
return nil
}
func (es *ElectronicSignature) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE electronic_signatures SET
status = @status,
signer_full_name = @signer_full_name,
signer_ip_address = @signer_ip_address,
signer_user_agent = @signer_user_agent,
file_hash = @file_hash,
seal = @seal,
seal_version = @seal_version,
tsa_token = @tsa_token,
signed_at = @signed_at,
certificate_file_id = @certificate_file_id,
certificate_processing_started_at = @certificate_processing_started_at,
attempt_count = @attempt_count,
max_attempts = @max_attempts,
last_attempted_at = @last_attempted_at,
last_error = @last_error,
processing_started_at = @processing_started_at,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": es.ID,
"status": es.Status,
"signer_full_name": es.SignerFullName,
"signer_ip_address": es.SignerIPAddress,
"signer_user_agent": es.SignerUserAgent,
"file_hash": es.FileHash,
"seal": es.Seal,
"seal_version": es.SealVersion,
"tsa_token": es.TSAToken,
"signed_at": es.SignedAt,
"certificate_file_id": es.CertificateFileID,
"certificate_processing_started_at": es.CertificateProcessingStartedAt,
"attempt_count": es.AttemptCount,
"max_attempts": es.MaxAttempts,
"last_attempted_at": es.LastAttemptedAt,
"last_error": es.LastError,
"processing_started_at": es.ProcessingStartedAt,
"updated_at": es.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update electronic signature: %w", err)
}
return nil
}
func (es *ElectronicSignature) LoadByID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
id gid.GID,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE %s AND id = @id
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": id}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query electronic signature: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadByOrgEmailAndDocType(
ctx context.Context,
conn pg.Conn,
scope Scoper,
orgID gid.GID,
email string,
docType ElectronicSignatureDocumentType,
fileID gid.GID,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE %s
AND organization_id = @organization_id
AND signer_email = @signer_email
AND document_type = @document_type
AND file_id = @file_id
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": orgID,
"signer_email": email,
"document_type": docType,
"file_id": fileID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query electronic signature: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
ctx context.Context,
conn pg.Conn,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE status = 'ACCEPTED' AND attempt_count < max_attempts
ORDER BY updated_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query accepted signatures: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
ctx context.Context,
conn pg.Conn,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE status = 'COMPLETED'
AND certificate_file_id IS NULL
AND certificate_processing_started_at IS NULL
ORDER BY signed_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query completed signatures: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func ResetStaleProcessingSignatures(
ctx context.Context,
conn pg.Conn,
staleAfter time.Duration,
) error {
q := `
UPDATE electronic_signatures
SET status = 'ACCEPTED', processing_started_at = NULL, updated_at = NOW()
WHERE status = 'PROCESSING'
AND processing_started_at < NOW() - $1::interval
`
_, err := conn.Exec(ctx, q, staleAfter.String())
if err != nil {
return fmt.Errorf("cannot reset stale processing signatures: %w", err)
}
return nil
}
func (es *ElectronicSignature) ComputeSeal(version int) (string, error) {
switch version {
case 1:
return es.computeSealV1()
default:
return "", fmt.Errorf("unsupported seal version %d", version)
}
}
func (es *ElectronicSignature) computeSealV1() (string, error) {
if es.SignedAt == nil {
return "", fmt.Errorf("signed_at must not be nil")
}
fields := []string{
es.ID.String(),
es.OrganizationID.String(),
es.DocumentType.String(),
es.FileID.String(),
ref.UnrefOrZero(es.FileHash),
ref.UnrefOrZero(es.SignerFullName),
strings.ToLower(es.SignerEmail),
ref.UnrefOrZero(es.SignerIPAddress),
ref.UnrefOrZero(es.SignerUserAgent),
es.ConsentText,
es.SignedAt.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano),
}
for i, f := range fields {
if f == "" {
return "", fmt.Errorf("seal field %d must not be empty", i)
}
if strings.Contains(f, "\n") {
return "", fmt.Errorf("seal field %d must not contain newline", i)
}
}
input := strings.Join(fields, "\n")
return hash.SHA256Hex([]byte(input)), nil
}
func ResetStaleCertificateProcessing(
ctx context.Context,
conn pg.Conn,
staleAfter time.Duration,
) error {
q := `
UPDATE electronic_signatures
SET certificate_processing_started_at = NULL, updated_at = NOW()
WHERE status = 'COMPLETED'
AND certificate_file_id IS NULL
AND certificate_processing_started_at IS NOT NULL
AND certificate_processing_started_at < NOW() - $1::interval
`
_, err := conn.Exec(ctx, q, staleAfter.String())
if err != nil {
return fmt.Errorf("cannot reset stale certificate processing: %w", err)
}
return nil
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
ElectronicSignatureDocumentType string
)
const (
ElectronicSignatureDocumentTypeNDA ElectronicSignatureDocumentType = "NDA"
ElectronicSignatureDocumentTypeDPA ElectronicSignatureDocumentType = "DPA"
ElectronicSignatureDocumentTypeMSA ElectronicSignatureDocumentType = "MSA"
ElectronicSignatureDocumentTypeSOW ElectronicSignatureDocumentType = "SOW"
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
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."
)
func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType {
return []ElectronicSignatureDocumentType{
ElectronicSignatureDocumentTypeNDA,
ElectronicSignatureDocumentTypeDPA,
ElectronicSignatureDocumentTypeMSA,
ElectronicSignatureDocumentTypeSOW,
ElectronicSignatureDocumentTypeSLA,
ElectronicSignatureDocumentTypeTOS,
ElectronicSignatureDocumentTypePrivacyPolicy,
ElectronicSignatureDocumentTypeOther,
}
}
func (dt ElectronicSignatureDocumentType) MarshalText() ([]byte, error) {
return []byte(dt.String()), nil
}
func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case ElectronicSignatureDocumentTypeNDA.String():
*dt = ElectronicSignatureDocumentTypeNDA
case ElectronicSignatureDocumentTypeDPA.String():
*dt = ElectronicSignatureDocumentTypeDPA
case ElectronicSignatureDocumentTypeMSA.String():
*dt = ElectronicSignatureDocumentTypeMSA
case ElectronicSignatureDocumentTypeSOW.String():
*dt = ElectronicSignatureDocumentTypeSOW
case ElectronicSignatureDocumentTypeSLA.String():
*dt = ElectronicSignatureDocumentTypeSLA
case ElectronicSignatureDocumentTypeTOS.String():
*dt = ElectronicSignatureDocumentTypeTOS
case ElectronicSignatureDocumentTypePrivacyPolicy.String():
*dt = ElectronicSignatureDocumentTypePrivacyPolicy
case ElectronicSignatureDocumentTypeOther.String():
*dt = ElectronicSignatureDocumentTypeOther
default:
return fmt.Errorf("invalid ElectronicSignatureDocumentType value: %q", val)
}
return nil
}
func (dt ElectronicSignatureDocumentType) String() string {
return string(dt)
}
func (dt *ElectronicSignatureDocumentType) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureDocumentType, expected string got %T", value)
}
return dt.UnmarshalText([]byte(val))
}
func (dt ElectronicSignatureDocumentType) Value() (driver.Value, error) {
return dt.String(), nil
}
func (dt ElectronicSignatureDocumentType) DisplayName() string {
switch dt {
case ElectronicSignatureDocumentTypeNDA:
return "Non-Disclosure Agreement"
case ElectronicSignatureDocumentTypeDPA:
return "Data Processing Agreement"
case ElectronicSignatureDocumentTypeMSA:
return "Master Service Agreement"
case ElectronicSignatureDocumentTypeSOW:
return "Statement of Work"
case ElectronicSignatureDocumentTypeSLA:
return "Service Level Agreement"
case ElectronicSignatureDocumentTypeTOS:
return "Terms of Service"
case ElectronicSignatureDocumentTypePrivacyPolicy:
return "Privacy Policy"
default:
return string(dt)
}
}
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 ElectronicSignatureDocumentTypeOther:
return "", fmt.Errorf("document type OTHER requires explicit consent text")
default:
return "", fmt.Errorf("unknown document type %q", dt)
}
return docAgreement + " " + ESignProcessConsentText, nil
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
ElectronicSignatureEvent struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
ElectronicSignatureID gid.GID `db:"electronic_signature_id"`
EventType ElectronicSignatureEventType `db:"event_type"`
EventSource ElectronicSignatureEventSource `db:"event_source"`
ActorEmail string `db:"actor_email"`
ActorIPAddress string `db:"actor_ip_address"`
ActorUserAgent string `db:"actor_user_agent"`
OccurredAt time.Time `db:"occurred_at"`
CreatedAt time.Time `db:"created_at"`
}
ElectronicSignatureEvents []*ElectronicSignatureEvent
)
func (e *ElectronicSignatureEvent) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO electronic_signature_events (
id, tenant_id, electronic_signature_id, event_type, event_source,
actor_email, actor_ip_address, actor_user_agent,
occurred_at, created_at
) VALUES (
@id, @tenant_id, @electronic_signature_id, @event_type, @event_source,
@actor_email, @actor_ip_address, @actor_user_agent,
@occurred_at, @created_at
)
`
args := pgx.StrictNamedArgs{
"id": e.ID,
"tenant_id": scope.GetTenantID(),
"electronic_signature_id": e.ElectronicSignatureID,
"event_type": e.EventType,
"event_source": e.EventSource,
"actor_email": e.ActorEmail,
"actor_ip_address": e.ActorIPAddress,
"actor_user_agent": e.ActorUserAgent,
"occurred_at": e.OccurredAt,
"created_at": e.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert electronic signature event: %w", err)
}
return nil
}
func (es *ElectronicSignatureEvents) LoadBySignatureID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
sigID gid.GID,
) error {
q := `
SELECT
id, tenant_id, electronic_signature_id, event_type, event_source,
actor_email, actor_ip_address, actor_user_agent,
occurred_at, created_at
FROM electronic_signature_events
WHERE %s AND electronic_signature_id = @electronic_signature_id
ORDER BY occurred_at ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"electronic_signature_id": sigID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query electronic signature events: %w", err)
}
events, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ElectronicSignatureEvent])
if err != nil {
return fmt.Errorf("cannot collect electronic signature events: %w", err)
}
*es = events
return nil
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
ElectronicSignatureEventSource string
)
const (
ElectronicSignatureEventSourceClient ElectronicSignatureEventSource = "CLIENT"
ElectronicSignatureEventSourceServer ElectronicSignatureEventSource = "SERVER"
)
func (s ElectronicSignatureEventSource) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
func (s *ElectronicSignatureEventSource) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case ElectronicSignatureEventSourceClient.String():
*s = ElectronicSignatureEventSourceClient
case ElectronicSignatureEventSourceServer.String():
*s = ElectronicSignatureEventSourceServer
default:
return fmt.Errorf("invalid ElectronicSignatureEventSource value: %q", val)
}
return nil
}
func (s ElectronicSignatureEventSource) String() string {
return string(s)
}
func (s *ElectronicSignatureEventSource) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureEventSource, expected string got %T", value)
}
return s.UnmarshalText([]byte(val))
}
func (s ElectronicSignatureEventSource) Value() (driver.Value, error) {
return s.String(), nil
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
ElectronicSignatureEventType string
)
const (
ElectronicSignatureEventTypeDocumentViewed ElectronicSignatureEventType = "DOCUMENT_VIEWED"
ElectronicSignatureEventTypeConsentGiven ElectronicSignatureEventType = "CONSENT_GIVEN"
ElectronicSignatureEventTypeFullNameTyped ElectronicSignatureEventType = "FULL_NAME_TYPED"
ElectronicSignatureEventTypeSignatureAccepted ElectronicSignatureEventType = "SIGNATURE_ACCEPTED"
ElectronicSignatureEventTypeSignatureCompleted ElectronicSignatureEventType = "SIGNATURE_COMPLETED"
ElectronicSignatureEventTypeSealComputed ElectronicSignatureEventType = "SEAL_COMPUTED"
ElectronicSignatureEventTypeTimestampRequested ElectronicSignatureEventType = "TIMESTAMP_REQUESTED"
ElectronicSignatureEventTypeCertificateGenerated ElectronicSignatureEventType = "CERTIFICATE_GENERATED"
ElectronicSignatureEventTypeProcessingError ElectronicSignatureEventType = "PROCESSING_ERROR"
)
func (t ElectronicSignatureEventType) MarshalText() ([]byte, error) {
return []byte(t.String()), nil
}
func (t *ElectronicSignatureEventType) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case ElectronicSignatureEventTypeDocumentViewed.String():
*t = ElectronicSignatureEventTypeDocumentViewed
case ElectronicSignatureEventTypeConsentGiven.String():
*t = ElectronicSignatureEventTypeConsentGiven
case ElectronicSignatureEventTypeFullNameTyped.String():
*t = ElectronicSignatureEventTypeFullNameTyped
case ElectronicSignatureEventTypeSignatureAccepted.String():
*t = ElectronicSignatureEventTypeSignatureAccepted
case ElectronicSignatureEventTypeSignatureCompleted.String():
*t = ElectronicSignatureEventTypeSignatureCompleted
case ElectronicSignatureEventTypeSealComputed.String():
*t = ElectronicSignatureEventTypeSealComputed
case ElectronicSignatureEventTypeTimestampRequested.String():
*t = ElectronicSignatureEventTypeTimestampRequested
case ElectronicSignatureEventTypeCertificateGenerated.String():
*t = ElectronicSignatureEventTypeCertificateGenerated
case ElectronicSignatureEventTypeProcessingError.String():
*t = ElectronicSignatureEventTypeProcessingError
default:
return fmt.Errorf("invalid ElectronicSignatureEventType value: %q", val)
}
return nil
}
func (t ElectronicSignatureEventType) String() string {
return string(t)
}
func (t *ElectronicSignatureEventType) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureEventType, expected string got %T", value)
}
return t.UnmarshalText([]byte(val))
}
func (t ElectronicSignatureEventType) Value() (driver.Value, error) {
return t.String(), nil
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
ElectronicSignatureStatus string
)
const (
ElectronicSignatureStatusPending ElectronicSignatureStatus = "PENDING"
ElectronicSignatureStatusAccepted ElectronicSignatureStatus = "ACCEPTED"
ElectronicSignatureStatusProcessing ElectronicSignatureStatus = "PROCESSING"
ElectronicSignatureStatusCompleted ElectronicSignatureStatus = "COMPLETED"
ElectronicSignatureStatusFailed ElectronicSignatureStatus = "FAILED"
)
func (s ElectronicSignatureStatus) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
func (s *ElectronicSignatureStatus) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case ElectronicSignatureStatusPending.String():
*s = ElectronicSignatureStatusPending
case ElectronicSignatureStatusAccepted.String():
*s = ElectronicSignatureStatusAccepted
case ElectronicSignatureStatusProcessing.String():
*s = ElectronicSignatureStatusProcessing
case ElectronicSignatureStatusCompleted.String():
*s = ElectronicSignatureStatusCompleted
case ElectronicSignatureStatusFailed.String():
*s = ElectronicSignatureStatusFailed
default:
return fmt.Errorf("invalid ElectronicSignatureStatus value: %q", val)
}
return nil
}
func (s ElectronicSignatureStatus) String() string {
return string(s)
}
func (s *ElectronicSignatureStatus) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureStatus, expected string got %T", value)
}
return s.UnmarshalText([]byte(val))
}
func (s ElectronicSignatureStatus) Value() (driver.Value, error) {
return s.String(), nil
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
EmailAttachment struct {
ID gid.GID `db:"id"`
EmailID gid.GID `db:"email_id"`
FileID gid.GID `db:"file_id"`
Filename string `db:"filename"`
// TODO (to drop)
ContentType string `db:"content_type"`
CreatedAt time.Time `db:"created_at"`
}
EmailAttachments []*EmailAttachment
)
func NewEmailAttachment(emailID, fileID gid.GID, filename, contentType string) *EmailAttachment {
return &EmailAttachment{
ID: gid.New(gid.NilTenant, EmailAttachmentEntityType),
EmailID: emailID,
FileID: fileID,
Filename: filename,
ContentType: contentType,
CreatedAt: time.Now(),
}
}
func (a *EmailAttachment) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO email_attachments (id, email_id, file_id, filename, content_type, created_at)
VALUES (@id, @email_id, @file_id, @filename, @content_type, @created_at)
`
args := pgx.StrictNamedArgs{
"id": a.ID,
"email_id": a.EmailID,
"file_id": a.FileID,
"filename": a.Filename,
"content_type": a.ContentType,
"created_at": a.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert email attachment: %w", err)
}
return nil
}
func (a *EmailAttachments) LoadByEmailID(
ctx context.Context,
conn pg.Conn,
emailID gid.GID,
) error {
q := `
SELECT id, email_id, file_id, filename, content_type, created_at
FROM email_attachments
WHERE email_id = @email_id
ORDER BY created_at ASC
`
args := pgx.StrictNamedArgs{
"email_id": emailID,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query email attachments: %w", err)
}
attachments, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[EmailAttachment])
if err != nil {
return fmt.Errorf("cannot collect email attachments: %w", err)
}
*a = attachments
return nil
}

View File

@@ -82,6 +82,9 @@ const (
WebhookSubscriptionEntityType uint16 = 56
WebhookDataEntityType uint16 = 57
WebhookEventEntityType uint16 = 58
ElectronicSignatureEntityType uint16 = 59
ElectronicSignatureEventEntityType uint16 = 60
EmailAttachmentEntityType uint16 = 61
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -200,6 +203,12 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &WebhookData{ID: id}, true
case WebhookEventEntityType:
return &WebhookEvent{ID: id}, true
case ElectronicSignatureEntityType:
return &ElectronicSignature{ID: id}, true
case ElectronicSignatureEventEntityType:
return &ElectronicSignatureEvent{ID: id}, true
case EmailAttachmentEntityType:
return &EmailAttachment{ID: id}, true
default:
return nil, false
}

View File

@@ -0,0 +1,94 @@
-- Electronic signature enums
CREATE TYPE electronic_signature_document_type AS ENUM (
'NDA', 'DPA', 'MSA', 'SOW', 'SLA', 'TOS', 'PRIVACY_POLICY', 'OTHER'
);
CREATE TYPE electronic_signature_status AS ENUM (
'PENDING',
'ACCEPTED',
'PROCESSING',
'COMPLETED',
'FAILED'
);
CREATE TYPE electronic_signature_event_type AS ENUM (
'DOCUMENT_VIEWED', 'CONSENT_GIVEN', 'FULL_NAME_TYPED',
'SIGNATURE_ACCEPTED',
'SIGNATURE_COMPLETED', 'SEAL_COMPUTED', 'TIMESTAMP_REQUESTED',
'CERTIFICATE_GENERATED',
'PROCESSING_ERROR'
);
CREATE TYPE electronic_signature_event_source AS ENUM ('CLIENT', 'SERVER');
-- Electronic signatures table
CREATE TABLE electronic_signatures (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
status electronic_signature_status NOT NULL DEFAULT 'PENDING',
document_type electronic_signature_document_type NOT NULL,
file_id TEXT NOT NULL REFERENCES files(id),
signer_email CITEXT NOT NULL,
-- Set at creation time (PENDING):
consent_text TEXT NOT NULL,
-- Set when status transitions to ACCEPTED (signer submits):
signer_full_name TEXT,
signer_ip_address TEXT,
signer_user_agent TEXT,
-- Set when status transitions to COMPLETED (worker finishes):
file_hash TEXT,
seal TEXT,
seal_version INT NOT NULL DEFAULT 1,
tsa_token BYTEA,
signed_at TIMESTAMPTZ,
-- Set by certificate worker after COMPLETED:
certificate_file_id TEXT REFERENCES files(id),
certificate_processing_started_at TIMESTAMPTZ,
-- Async processing state:
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 10,
last_attempted_at TIMESTAMPTZ,
last_error TEXT,
processing_started_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
-- file_id in the constraint allows re-signing when the org replaces the NDA file.
UNIQUE(organization_id, signer_email, document_type, file_id)
);
-- Electronic signature events table
CREATE TABLE electronic_signature_events (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
electronic_signature_id TEXT NOT NULL REFERENCES electronic_signatures(id) ON DELETE CASCADE,
event_type electronic_signature_event_type NOT NULL,
event_source electronic_signature_event_source NOT NULL,
actor_email CITEXT NOT NULL,
actor_ip_address TEXT NOT NULL,
actor_user_agent TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_esig_events_signature
ON electronic_signature_events (electronic_signature_id, occurred_at ASC);
-- Email attachments table (generic, not esign-specific)
CREATE TABLE email_attachments (
id TEXT PRIMARY KEY,
email_id TEXT NOT NULL REFERENCES emails(id) ON DELETE CASCADE,
file_id TEXT NOT NULL REFERENCES files(id),
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_email_attachments_email_id ON email_attachments (email_id);

25
pkg/crypto/hash/hash.go Normal file
View File

@@ -0,0 +1,25 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package hash
import (
"crypto/sha256"
"encoding/hex"
)
func SHA256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}

167
pkg/esign/certgen.go Normal file
View File

@@ -0,0 +1,167 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package esign
import (
"bytes"
"context"
_ "embed"
"fmt"
"io"
"strings"
"text/template"
"time"
"github.com/digitorus/timestamp"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/html2pdf"
)
type (
CertificateGenerator struct {
HTML2PDFConverter *html2pdf.Converter
}
certificateData struct {
SignatureID string
OrganizationID string
SignerFullName string
SignerEmail string
SignerIPAddress string
SignerUserAgent string
DocumentType string
DocumentTypeName string
FileID string
FileHash string
Seal string
SealVersion int
ConsentText string
SignedAt string
TSAAuthority string
TSATime string
TSASerial string
Events []certificateEvent
}
certificateEvent struct {
EventType string
Source string
Actor string
IPAddress string
OccurredAt string
}
)
var (
//go:embed certificate.html.tmpl
certificateTemplateHTML string
certificateTemplate = template.Must(template.New("certificate").Parse(certificateTemplateHTML))
)
func (g *CertificateGenerator) Generate(
ctx context.Context,
signature *coredata.ElectronicSignature,
events coredata.ElectronicSignatureEvents,
) (io.Reader, error) {
data := certificateData{
SignatureID: signature.ID.String(),
OrganizationID: signature.OrganizationID.String(),
SignerFullName: ref.UnrefOrZero(signature.SignerFullName),
SignerEmail: signature.SignerEmail,
SignerIPAddress: ref.UnrefOrZero(signature.SignerIPAddress),
SignerUserAgent: ref.UnrefOrZero(signature.SignerUserAgent),
DocumentType: signature.DocumentType.String(),
DocumentTypeName: signature.DocumentType.DisplayName(),
FileID: signature.FileID.String(),
FileHash: ref.UnrefOrZero(signature.FileHash),
Seal: ref.UnrefOrZero(signature.Seal),
SealVersion: signature.SealVersion,
ConsentText: signature.ConsentText,
}
if signature.SignedAt == nil {
return nil, fmt.Errorf("cannot generate certificate: signature %s has no signed_at timestamp", signature.ID)
}
data.SignedAt = signature.SignedAt.UTC().Format(time.RFC3339)
if len(signature.TSAToken) == 0 {
return nil, fmt.Errorf("cannot generate certificate: signature %s has no TSA token", signature.ID)
}
tsResp, err := timestamp.ParseResponse(signature.TSAToken)
if err != nil {
return nil, fmt.Errorf("cannot parse TSA token for signature %s: %w", signature.ID, err)
}
data.TSATime = tsResp.Time.UTC().Format(time.RFC3339)
data.TSASerial = tsResp.SerialNumber.String()
data.TSAAuthority = tsaAuthorityName(tsResp)
for _, evt := range events {
data.Events = append(
data.Events,
certificateEvent{
EventType: evt.EventType.String(),
Source: evt.EventSource.String(),
Actor: evt.ActorEmail,
IPAddress: evt.ActorIPAddress,
OccurredAt: evt.OccurredAt.UTC().Format(time.RFC3339),
},
)
}
var htmlBuf bytes.Buffer
if err := certificateTemplate.Execute(&htmlBuf, data); err != nil {
return nil, fmt.Errorf("cannot render certificate template: %w", err)
}
pdfReader, err := g.HTML2PDFConverter.GeneratePDF(
ctx,
htmlBuf.Bytes(),
html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
MarginTop: html2pdf.NewMarginMillimeters(20),
MarginBottom: html2pdf.NewMarginMillimeters(20),
MarginLeft: html2pdf.NewMarginMillimeters(20),
MarginRight: html2pdf.NewMarginMillimeters(20),
},
)
if err != nil {
return nil, fmt.Errorf("cannot generate certificate PDF: %w", err)
}
return pdfReader, nil
}
func tsaAuthorityName(ts *timestamp.Timestamp) string {
if len(ts.Certificates) == 0 {
return ""
}
cert := ts.Certificates[0]
if len(cert.Subject.Organization) > 0 {
org := strings.Join(cert.Subject.Organization, ", ")
if cert.Subject.CommonName != "" {
return org + " (" + cert.Subject.CommonName + ")"
}
return org
}
return cert.Subject.CommonName
}

View File

@@ -0,0 +1,520 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 9.5px;
color: #000;
line-height: 1.4;
}
/* -- Top bar -- */
.top-bar {
border-top: 4px solid #000;
padding-top: 10px;
margin-bottom: 6px;
display: table;
width: 100%;
}
.top-bar-left {
display: table-cell;
vertical-align: top;
}
.top-bar-right {
display: table-cell;
vertical-align: top;
text-align: right;
width: 120px;
}
.brand {
font-size: 14px;
font-weight: 700;
color: #000;
letter-spacing: 0.5px;
}
.cert-title {
font-size: 16px;
font-weight: 700;
font-style: italic;
color: #000;
border-bottom: 2px solid #000;
padding-bottom: 4px;
display: inline-block;
margin-bottom: 6px;
}
/* -- Meta line (ID + Status) -- */
.meta-line {
display: table;
width: 100%;
margin-bottom: 2px;
font-size: 9.5px;
}
.meta-left {
display: table-cell;
vertical-align: top;
}
.meta-right {
display: table-cell;
vertical-align: top;
text-align: right;
}
/* -- Info rows -- */
.info-row {
font-size: 9.5px;
line-height: 1.5;
margin-bottom: 1px;
}
/* -- Info grid (multi-column) -- */
.info-grid {
display: table;
width: 100%;
margin-bottom: 2px;
}
.info-col {
display: table-cell;
vertical-align: top;
font-size: 9.5px;
line-height: 1.5;
}
.info-col-left {
width: 38%;
}
.info-col-mid {
width: 30%;
}
.info-col-right {
width: 32%;
}
/* -- Section header bar -- */
.section-bar {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.section-bar td {
padding: 5px 6px;
font-size: 9.5px;
font-weight: 700;
color: #000;
background: #e8e8e8;
border-top: 2px solid #000;
}
/* -- Signer content -- */
.signer-table {
width: 100%;
border-collapse: collapse;
}
.signer-table td {
padding: 6px 6px;
vertical-align: top;
font-size: 9.5px;
line-height: 1.5;
}
.signer-name {
font-weight: 700;
font-size: 9.5px;
}
/* -- Events table -- */
.events-table {
width: 100%;
border-collapse: collapse;
font-size: 9px;
}
.events-table thead td {
padding: 5px 6px;
font-weight: 700;
color: #000;
background: #e8e8e8;
border-top: 2px solid #000;
}
.events-table tbody td {
padding: 4px 6px;
border-bottom: 1px solid #ddd;
font-size: 9px;
}
/* -- Detail table (key-value) -- */
.detail-table {
width: 100%;
border-collapse: collapse;
}
.detail-table td {
padding: 3px 6px;
vertical-align: top;
font-size: 9px;
border-bottom: 1px solid #ddd;
}
.detail-table .lbl {
width: 170px;
font-weight: 700;
color: #333;
}
/* -- Mono -- */
.mono {
font-family: "Courier New", Courier, monospace;
font-size: 8.5px;
word-break: break-all;
}
/* -- Consent -- */
.consent-box {
padding: 6px;
font-size: 9px;
line-height: 1.6;
color: #333;
border-bottom: 1px solid #ddd;
}
/* -- Verification -- */
.verification-box {
padding: 6px;
font-size: 9px;
line-height: 1.6;
color: #333;
}
.verification-box ol {
margin: 4px 0 0 18px;
padding: 0;
}
/* -- ERSD -- */
.ersd-section {
page-break-before: always;
}
.ersd-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: #000;
border-bottom: 2px solid #000;
padding-bottom: 4px;
margin-bottom: 10px;
}
.ersd-body {
font-size: 9.5px;
color: #000;
line-height: 1.7;
}
.ersd-body p {
margin-bottom: 6px;
}
.ersd-body h3 {
font-size: 9.5px;
font-weight: 700;
margin-top: 10px;
margin-bottom: 2px;
}
.ersd-body ul {
margin: 2px 0 6px 18px;
padding: 0;
}
/* -- Footer -- */
.footer {
margin-top: 16px;
padding-top: 6px;
border-top: 1px solid #999;
font-size: 8px;
color: #777;
line-height: 1.5;
}
</style>
</head>
<body>
<!-- Top bar with title and brand -->
<div class="top-bar">
<div class="top-bar-left">
<div class="cert-title">Certificate Of Completion</div>
</div>
<div class="top-bar-right">
<div class="brand">probo</div>
</div>
</div>
<!-- Signature ID + Status -->
<div class="meta-line">
<div class="meta-left">
Signature Id: {{.SignatureID}}
</div>
<div class="meta-right">
<strong>Status: Completed</strong>
</div>
</div>
<!-- Subject -->
<div class="info-row">
<strong>Subject:</strong> {{.DocumentTypeName}}
</div>
<!-- Document / Source info grid -->
<div class="info-grid" style="margin-top: 4px;">
<div class="info-col info-col-left">
<div>Document Type: {{.DocumentType}}</div>
<div>File Hash (SHA-256):</div>
<div class="mono">{{.FileHash}}</div>
</div>
<div class="info-col info-col-mid">
&nbsp;
</div>
<div class="info-col info-col-right">
<div>Organization Id:</div>
<div class="mono">{{.OrganizationID}}</div>
<div style="margin-top: 2px;">File Id:</div>
<div class="mono">{{.FileID}}</div>
</div>
</div>
<!-- Signer Events -->
<table class="section-bar">
<tr>
<td style="width: 40%;">Signer Events</td>
<td style="width: 60%;">Timestamp</td>
</tr>
</table>
<table class="signer-table">
<tr>
<td style="width: 40%;">
<div class="signer-name">{{.SignerFullName}}</div>
<div>{{.SignerEmail}}</div>
<div>Security Level: Email, Account Authentication</div>
<div>Using IP Address: {{.SignerIPAddress}}</div>
<div style="margin-top: 6px; font-size: 8.5px;">
<strong>Electronic Record and Signature Disclosure:</strong><br/>
Accepted
</div>
</td>
<td style="width: 60%;">
<div><strong>Signed:</strong> {{.SignedAt}}</div>
<div style="margin-top: 8px; font-size: 8.5px;">
User Agent: {{.SignerUserAgent}}
</div>
</td>
</tr>
</table>
<!-- Electronic Consent -->
<table class="section-bar">
<tr>
<td>Electronic Consent</td>
</tr>
</table>
<div class="consent-box">{{.ConsentText}}</div>
<!-- Signing Summary Events -->
<table class="events-table">
<thead>
<tr>
<td>Signing Summary Events</td>
<td>Status</td>
<td>Timestamps</td>
</tr>
</thead>
<tbody>
{{range .Events}}
<tr>
<td>{{.EventType}}</td>
<td>{{.Source}}</td>
<td>{{.OccurredAt}}</td>
</tr>
{{end}}
</tbody>
</table>
<!-- Integrity Verification -->
<table class="section-bar">
<tr>
<td>Integrity Verification</td>
</tr>
</table>
<table class="detail-table">
<tr>
<td class="lbl">Seal (SHA-256, v{{.SealVersion}})</td>
<td><span class="mono">{{.Seal}}</span></td>
</tr>
{{if .TSAAuthority}}
<tr>
<td class="lbl">TSA Authority</td>
<td>{{.TSAAuthority}}</td>
</tr>
{{end}}
{{if .TSATime}}
<tr>
<td class="lbl">TSA Timestamp</td>
<td>{{.TSATime}}</td>
</tr>
{{end}}
{{if .TSASerial}}
<tr>
<td class="lbl">TSA Serial Number</td>
<td>{{.TSASerial}}</td>
</tr>
{{end}}
</table>
<!-- Seal Verification -->
<table class="section-bar">
<tr>
<td>Seal Verification (v{{.SealVersion}})</td>
</tr>
</table>
<div class="verification-box">
To independently verify the integrity seal, compute the SHA-256 hash of
the following fields joined by newline characters (<span class="mono">\n</span>),
in this exact order:
<ol>
<li>Signature ID</li>
<li>Organization ID</li>
<li>Document Type (the raw value, e.g. <span class="mono">NDA</span>)</li>
<li>File ID</li>
<li>File Hash (SHA-256, lowercase hex)</li>
<li>Signer Full Name</li>
<li>Signer Email (lowercased)</li>
<li>Signer IP Address</li>
<li>Signer User Agent</li>
<li>Consent Text (the full text shown above)</li>
<li>Signed At (UTC, RFC 3339 with nanoseconds, truncated to microsecond precision)</li>
</ol>
<div style="margin-top: 4px;">
The resulting lowercase hex-encoded SHA-256 digest must match the seal
shown above.
</div>
</div>
<!-- Electronic Record and Signature Disclosure -->
<div class="ersd-section">
<div class="ersd-title">Electronic Record and Signature Disclosure</div>
<div class="ersd-body">
<p>
From time to time, the organization identified in this certificate (we, us, or
the Company) may be required by law to provide to you certain written notices or
disclosures. Described below are the terms and conditions for providing to you
such notices and disclosures electronically through the Probo platform. Please
read the information below carefully and thoroughly.
</p>
<h3>Withdrawing your consent</h3>
<p>
If you decide to receive notices and disclosures from us electronically, you may
at any time change your mind and tell us that you wish to withdraw your consent to
receive notices and disclosures electronically. How you must inform us of your
decision is described below.
</p>
<h3>All notices and disclosures will be sent to you electronically</h3>
<p>
Unless you tell us otherwise in accordance with the procedures described herein, we
will provide electronically to you through the Probo platform all required notices,
disclosures, authorizations, acknowledgements, and other documents that are required
to be provided or made available to you during the course of our relationship with
you. To reduce the chance of you inadvertently not receiving any notice or
disclosure, we prefer to provide all of the required notices and disclosures to you
by the same method and to the same address that you have given us.
</p>
<h3>How to contact us</h3>
<p>
You may contact us to let us know of your changes as to how we may contact you
electronically, and to withdraw your prior consent to receive notices and
disclosures electronically. To reach us, please send an email to
<strong>legal@getprobo.com</strong>.
</p>
<h3>To advise us of your new email address</h3>
<p>
To let us know of a change in your email address where we should send notices and
disclosures electronically to you, you must send an email message to us at
legal@getprobo.com and in the body of such request you must state your previous
email address and your new email address. We do not require any other information
from you to change your email address.
</p>
<h3>To withdraw your consent</h3>
<p>
To inform us that you no longer wish to receive future notices and disclosures in
electronic format you may:
</p>
<ul>
<li>
decline to sign a document from within your signing session, and on the
subsequent page, indicate you wish to withdraw your consent; or
</li>
<li>
send us an email to legal@getprobo.com and in the body of such request you
must state your email, full name, mailing address, and telephone number. We do
not need any other information from you to withdraw consent. The consequences of
your withdrawing consent for online documents will be that transactions may take
a longer time to process.
</li>
</ul>
<h3>Acknowledging your access and consent to receive and sign documents electronically</h3>
<p>
By signing the document electronically through the Probo platform, you confirm
that:
</p>
<ul>
<li>
You can access and read this Electronic Record and Signature Disclosure; and
</li>
<li>
You can print on paper this Electronic Record and Signature Disclosure, or save
or send this Electronic Record and Signature Disclosure to a location where you
can print it, for future reference and access; and
</li>
<li>
Until or unless you notify us as described above, you consent to receive
exclusively through electronic means all notices, disclosures, authorizations,
acknowledgements, and other documents that are required to be provided or made
available to you during the course of your relationship with us.
</li>
</ul>
</div>
</div>
<!-- Footer -->
<div class="footer">
This certificate was generated automatically by Probo. The integrity
seal and TSA timestamp provide tamper-evident proof of the signing
event. All information required to independently recompute and verify
the seal is included in this document.
</div>
</body>
</html>

View File

@@ -0,0 +1,375 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package esign
import (
"context"
"errors"
"fmt"
"sync"
"time"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
emails "go.probo.inc/probo/packages/emails"
)
// EmailPresenterConfigFunc resolves the emails.PresenterConfig for the
// organization that owns the given trust center.
type EmailPresenterConfigFunc func(ctx context.Context, organizationID gid.GID) (emails.PresenterConfig, error)
type (
CompletionCertificateWorker struct {
pg *pg.Client
fileManager *filemanager.Service
certificateGen *CertificateGenerator
presenterConfigFunc EmailPresenterConfigFunc
bucket string
logger *log.Logger
interval time.Duration
staleAfter time.Duration
maxConcurrency int
}
CompletionCertificateWorkerOption func(*CompletionCertificateWorker)
)
const (
certificateFilename = "certificate-of-completion.pdf"
)
func WithCompletionCertificateWorkerInterval(d time.Duration) CompletionCertificateWorkerOption {
return func(w *CompletionCertificateWorker) { w.interval = d }
}
func WithCompletionCertificateWorkerStaleAfter(d time.Duration) CompletionCertificateWorkerOption {
return func(w *CompletionCertificateWorker) { w.staleAfter = d }
}
func WithCompletionCertificateWorkerMaxConcurrency(n int) CompletionCertificateWorkerOption {
return func(w *CompletionCertificateWorker) {
if n > 0 {
w.maxConcurrency = n
}
}
}
func NewCompletionCertificateWorker(
pgClient *pg.Client,
fileManager *filemanager.Service,
certificateGen *CertificateGenerator,
presenterConfigFunc EmailPresenterConfigFunc,
bucket string,
logger *log.Logger,
opts ...CompletionCertificateWorkerOption,
) *CompletionCertificateWorker {
w := &CompletionCertificateWorker{
pg: pgClient,
fileManager: fileManager,
certificateGen: certificateGen,
presenterConfigFunc: presenterConfigFunc,
bucket: bucket,
logger: logger,
interval: 10 * time.Second,
staleAfter: 10 * time.Minute,
maxConcurrency: 5,
}
for _, opt := range opts {
opt(w)
}
return w
}
func (w *CompletionCertificateWorker) Run(ctx context.Context) error {
var (
wg sync.WaitGroup
sem = make(chan struct{}, w.maxConcurrency)
)
defer wg.Wait()
LOOP:
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(w.interval):
// From there we should not accept cancelations anymore.
nonCancelableCtx := context.WithoutCancel(ctx)
w.recoverStaleCertificateRows(nonCancelableCtx)
for {
if err := w.processNext(nonCancelableCtx, sem, &wg); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
w.logger.ErrorCtx(nonCancelableCtx, "cannot process certificate", log.Error(err))
}
break
}
}
goto LOOP
}
}
func (w *CompletionCertificateWorker) processNext(ctx context.Context, sem chan struct{}, wg *sync.WaitGroup) error {
select {
case sem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
var (
signature coredata.ElectronicSignature
now = time.Now()
)
if err := w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := signature.LoadNextCompletedWithoutCertificateForUpdate(ctx, tx); err != nil {
return err
}
scope := coredata.NewScopeFromObjectID(signature.ID)
signature.CertificateProcessingStartedAt = &now
signature.UpdatedAt = now
if err := signature.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update signature: %w", err)
}
return nil
},
); err != nil {
<-sem
return err
}
wg.Add(1)
go func(signature coredata.ElectronicSignature) {
defer wg.Done()
defer func() { <-sem }()
scope := coredata.NewScopeFromObjectID(signature.ID)
if err := w.generateAndCommit(ctx, &signature); err != nil {
if err := w.handleCertFailure(ctx, &signature, scope, err); err != nil {
w.logger.ErrorCtx(ctx, "cannot handle certificate failure", log.Error(err))
}
}
}(signature)
return nil
}
func (w *CompletionCertificateWorker) generateAndCommit(
ctx context.Context,
signature *coredata.ElectronicSignature,
) error {
var (
scope = coredata.NewScopeFromObjectID(signature.ID)
)
email, attachments, err := w.generateCertificate(ctx, signature, scope)
if err != nil {
return err
}
if err := w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
signature.CertificateFileID = &attachments[1].FileID
signature.UpdatedAt = time.Now()
if err := signature.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update signature: %w", err)
}
event := signature.NewEvent(
coredata.ElectronicSignatureEventTypeCertificateGenerated,
coredata.ElectronicSignatureEventSourceServer,
)
if err := event.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert certificate event: %w", err)
}
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert certificate email: %w", err)
}
for _, attachment := range attachments {
if err := attachment.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email attachment: %w", err)
}
}
return nil
},
); err != nil {
return err
}
return nil
}
func (w *CompletionCertificateWorker) generateCertificate(
ctx context.Context,
signature *coredata.ElectronicSignature,
scope coredata.Scoper,
) (*coredata.Email, coredata.EmailAttachments, error) {
var (
events = coredata.ElectronicSignatureEvents{}
signedFile = coredata.File{}
)
if err := w.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := events.LoadBySignatureID(ctx, conn, scope, signature.ID); err != nil {
return fmt.Errorf("cannot load events: %w", err)
}
if err := signedFile.LoadByID(ctx, conn, scope, signature.FileID); err != nil {
return fmt.Errorf("cannot load signed file: %w", err)
}
return nil
},
); err != nil {
return nil, nil, err
}
certificatePDFReader, err := w.certificateGen.Generate(ctx, signature, events)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate certificate: %w", err)
}
certificateOfCompletionFile := coredata.File{
ID: gid.New(scope.GetTenantID(), coredata.FileEntityType),
OrganizationID: signature.OrganizationID,
BucketName: w.bucket,
MimeType: "application/pdf",
FileName: certificateFilename,
FileKey: uuid.MustNewV4().String(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certificateOfCompletionFileSize, err := w.fileManager.PutFile(
ctx,
&certificateOfCompletionFile,
certificatePDFReader,
map[string]string{
"type": "certificate-of-completion",
"signature-id": signature.ID.String(),
},
)
if err != nil {
return nil, nil, fmt.Errorf("cannot upload cert to S3: %w", err)
}
certificateOfCompletionFile.FileSize = certificateOfCompletionFileSize
if err := w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := certificateOfCompletionFile.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert certificate of completion file: %w", err)
}
return nil
},
); err != nil {
return nil, nil, err
}
presenterCfg, err := w.presenterConfigFunc(ctx, signature.OrganizationID)
if err != nil {
return nil, nil, fmt.Errorf("cannot resolve presenter config: %w", err)
}
emailPresenter := emails.NewPresenterFromConfig(w.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
docTypeName := signature.DocumentType.DisplayName()
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docTypeName)
if err != nil {
return nil, nil, fmt.Errorf("cannot render email: %w", err)
}
email := coredata.NewEmail(
ref.UnrefOrZero(signature.SignerFullName),
mail.Addr(signature.SignerEmail),
subject,
textBody,
htmlBody,
)
attachments := coredata.EmailAttachments{
coredata.NewEmailAttachment(
email.ID,
signedFile.ID,
signedFile.FileName,
signedFile.MimeType,
),
coredata.NewEmailAttachment(
email.ID,
certificateOfCompletionFile.ID,
certificateFilename,
"application/pdf",
),
}
return email, attachments, nil
}
func (w *CompletionCertificateWorker) handleCertFailure(
ctx context.Context,
signature *coredata.ElectronicSignature,
scope coredata.Scoper,
processingError error,
) error {
w.logger.ErrorCtx(ctx, "certificate worker failure",
log.Error(processingError),
log.String("signature_id", signature.ID.String()),
)
return w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
signature.CertificateProcessingStartedAt = nil
signature.UpdatedAt = time.Now()
if err := signature.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update signature: %w", err)
}
return nil
},
)
}
func (w *CompletionCertificateWorker) recoverStaleCertificateRows(ctx context.Context) {
if err := w.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return coredata.ResetStaleCertificateProcessing(ctx, conn, w.staleAfter)
},
); err != nil {
w.logger.ErrorCtx(ctx, "cannot recover stale certificates", log.Error(err))
}
}

344
pkg/esign/sealing_worker.go Normal file
View File

@@ -0,0 +1,344 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package esign
import (
"context"
"errors"
"fmt"
"sync"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/hash"
"go.probo.inc/probo/pkg/filemanager"
)
var (
ErrLoadFile = errors.New("esign: cannot load file")
ErrDownloadPDF = errors.New("esign: cannot download PDF")
ErrComputeSeal = errors.New("esign: cannot compute seal")
ErrTSATimestamp = errors.New("esign: cannot get TSA timestamp")
)
const (
currentSealVersion = 1
)
type (
SealingWorker struct {
pg *pg.Client
fileManager *filemanager.Service
tsaClient *TSAClient
logger *log.Logger
interval time.Duration
tsaTimeout time.Duration
staleAfter time.Duration
maxConcurrency int
}
SealingWorkerOption func(*SealingWorker)
)
func WithSealingWorkerInterval(d time.Duration) SealingWorkerOption {
return func(w *SealingWorker) { w.interval = d }
}
func WithSealingWorkerTSATimeout(d time.Duration) SealingWorkerOption {
return func(w *SealingWorker) { w.tsaTimeout = d }
}
func WithSealingWorkerStaleAfter(d time.Duration) SealingWorkerOption {
return func(w *SealingWorker) { w.staleAfter = d }
}
func WithSealingWorkerMaxConcurrency(n int) SealingWorkerOption {
return func(w *SealingWorker) {
if n > 0 {
w.maxConcurrency = n
}
}
}
func NewSealingWorker(
pgClient *pg.Client,
fileManager *filemanager.Service,
tsaClient *TSAClient,
logger *log.Logger,
opts ...SealingWorkerOption,
) *SealingWorker {
w := &SealingWorker{
pg: pgClient,
fileManager: fileManager,
tsaClient: tsaClient,
logger: logger,
interval: 10 * time.Second,
tsaTimeout: 10 * time.Second,
staleAfter: 5 * time.Minute,
maxConcurrency: 5,
}
for _, opt := range opts {
opt(w)
}
return w
}
func (w *SealingWorker) Run(ctx context.Context) error {
var (
wg sync.WaitGroup
sem = make(chan struct{}, w.maxConcurrency)
)
defer wg.Wait()
LOOP:
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(w.interval):
// From there we should not accept cancelations anymore.
nonCancelableCtx := context.WithoutCancel(ctx)
w.recoverStaleRows(nonCancelableCtx)
for {
if err := w.processNext(nonCancelableCtx, sem, &wg); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
w.logger.ErrorCtx(nonCancelableCtx, "cannot claim signature", log.Error(err))
}
break
}
}
goto LOOP
}
}
func (w *SealingWorker) processNext(ctx context.Context, sem chan struct{}, wg *sync.WaitGroup) error {
select {
case sem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
var (
signature = coredata.ElectronicSignature{}
now = time.Now()
)
if err := w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := signature.LoadNextAcceptedForUpdateSkipLocked(ctx, tx); err != nil {
return err
}
signature.Status = coredata.ElectronicSignatureStatusProcessing
signature.ProcessingStartedAt = &now
signature.AttemptCount++
signature.LastAttemptedAt = &now
signature.UpdatedAt = now
if err := signature.Update(ctx, tx, coredata.NewNoScope()); err != nil {
return fmt.Errorf("cannot update signature: %w", err)
}
return nil
},
); err != nil {
<-sem
return err
}
wg.Add(1)
go func(signature coredata.ElectronicSignature) {
defer wg.Done()
defer func() { <-sem }()
if err := w.sealAndCommit(ctx, &signature); err != nil {
if err := w.failSignature(ctx, &signature, err); err != nil {
w.logger.ErrorCtx(ctx, "cannot fail signature", log.Error(err))
}
}
}(signature)
return nil
}
func (w *SealingWorker) sealAndCommit(
ctx context.Context,
signature *coredata.ElectronicSignature,
) error {
var (
scope = coredata.NewScopeFromObjectID(signature.ID)
file coredata.File
events []coredata.ElectronicSignatureEvent
)
if err := w.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := file.LoadByID(ctx, conn, scope, signature.FileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
); err != nil {
return fmt.Errorf("%w: %w", ErrLoadFile, err)
}
pdfBytes, err := w.fileManager.GetFileBytes(ctx, &file)
if err != nil {
return fmt.Errorf("%w: %w", ErrDownloadPDF, err)
}
fileHash := hash.SHA256Hex(pdfBytes)
signature.FileHash = &fileHash
seal, err := signature.ComputeSeal(currentSealVersion)
if err != nil {
return fmt.Errorf("%w: %w", ErrComputeSeal, err)
}
signature.Seal = &seal
signature.SealVersion = currentSealVersion
events = append(
events,
signature.NewEvent(
coredata.ElectronicSignatureEventTypeSealComputed,
coredata.ElectronicSignatureEventSourceServer,
),
)
tsaCtx, cancel := context.WithTimeout(ctx, w.tsaTimeout)
defer cancel()
tsaToken, err := w.tsaClient.Timestamp(tsaCtx, []byte(seal))
if err != nil {
return fmt.Errorf("%w: %w", ErrTSATimestamp, err)
}
signature.TSAToken = tsaToken
events = append(
events,
signature.NewEvent(
coredata.ElectronicSignatureEventTypeTimestampRequested,
coredata.ElectronicSignatureEventSourceServer,
),
)
if err := w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var current coredata.ElectronicSignature
if err := current.LoadByID(ctx, tx, scope, signature.ID); err != nil {
return fmt.Errorf("cannot load signature: %w", err)
}
if current.Status != coredata.ElectronicSignatureStatusProcessing {
return fmt.Errorf("esign: unexpected status %s, expected PROCESSING", current.Status)
}
signature.Status = coredata.ElectronicSignatureStatusCompleted
signature.UpdatedAt = time.Now()
if err := signature.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update signature: %w", err)
}
events = append(
events,
signature.NewEvent(
coredata.ElectronicSignatureEventTypeSignatureCompleted,
coredata.ElectronicSignatureEventSourceServer,
),
)
for i := range events {
if err := events[i].Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert %s event: %w", events[i].EventType, err)
}
}
return nil
},
); err != nil {
return fmt.Errorf("cannot commit signing results: %w", err)
}
return nil
}
func (w *SealingWorker) failSignature(
ctx context.Context,
signature *coredata.ElectronicSignature,
processingError error,
) error {
scope := coredata.NewScopeFromObjectID(signature.ID)
w.logger.ErrorCtx(ctx, "sealing worker failure",
log.Error(processingError),
log.String("signature_id", signature.ID.String()),
)
return w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
errStr := userFacingError(processingError)
signature.LastError = &errStr
signature.ProcessingStartedAt = nil
signature.UpdatedAt = time.Now()
if signature.AttemptCount >= signature.MaxAttempts {
signature.Status = coredata.ElectronicSignatureStatusFailed
} else {
signature.Status = coredata.ElectronicSignatureStatusAccepted
}
if err := signature.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update signature: %w", err)
}
event := signature.NewEvent(coredata.ElectronicSignatureEventTypeProcessingError, coredata.ElectronicSignatureEventSourceServer)
if err := event.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert PROCESSING_ERROR event: %w", err)
}
return nil
},
)
}
func userFacingError(err error) string {
switch {
case errors.Is(err, ErrTSATimestamp):
return "The timestamp authority is temporarily unavailable."
case errors.Is(err, ErrLoadFile):
return "Unable to load the document for signing."
case errors.Is(err, ErrDownloadPDF):
return "Unable to retrieve the document."
case errors.Is(err, ErrComputeSeal):
return "Unable to generate the cryptographic seal."
default:
return "An unexpected error occurred while processing your signature."
}
}
func (w *SealingWorker) recoverStaleRows(ctx context.Context) {
if err := w.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return coredata.ResetStaleProcessingSignatures(ctx, conn, w.staleAfter)
},
); err != nil {
w.logger.ErrorCtx(ctx, "cannot recover stale signatures", log.Error(err))
}
}

352
pkg/esign/service.go Normal file
View File

@@ -0,0 +1,352 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package esign
import (
"context"
"fmt"
"strings"
"time"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/mail"
"golang.org/x/sync/errgroup"
)
// Service manages the electronic signature lifecycle.
type (
Service struct {
pg *pg.Client
fileManager *filemanager.Service
tsaClient *TSAClient
certificateGen *CertificateGenerator
bucket string
logger *log.Logger
}
CreateSignatureRequest struct {
OrganizationID gid.GID
DocumentType coredata.ElectronicSignatureDocumentType
FileID gid.GID
SignerEmail mail.Addr
ConsentText string // optional; required when DocumentType == OTHER
}
AcceptSignatureRequest struct {
SignatureID gid.GID
SignerFullName string
SignerEmail mail.Addr
SignerIPAddr string
SignerUA string
}
RecordEventRequest struct {
SignatureID gid.GID
EventType coredata.ElectronicSignatureEventType
EventSource coredata.ElectronicSignatureEventSource
ActorEmail mail.Addr
ActorIPAddr string
ActorUA string
}
)
func NewService(
pgClient *pg.Client,
fileManager *filemanager.Service,
html2pdfConverter *html2pdf.Converter,
tsaURL string,
bucket string,
logger *log.Logger,
) *Service {
httpClient := httpclient.DefaultPooledClient(
httpclient.WithLogger(logger),
)
return &Service{
pg: pgClient,
fileManager: fileManager,
tsaClient: &TSAClient{URL: tsaURL, HTTPClient: httpClient},
certificateGen: &CertificateGenerator{
HTML2PDFConverter: html2pdfConverter,
},
bucket: bucket,
logger: logger,
}
}
func (s *Service) Run(ctx context.Context, presenterConfigFunc EmailPresenterConfigFunc) error {
g := errgroup.Group{}
sealingWorkerCtx, stopSealingWorker := context.WithCancel(context.Background())
sealingWorker := NewSealingWorker(
s.pg,
s.fileManager,
s.tsaClient,
s.logger.Named("sealing-worker"),
)
g.Go(func() error { return sealingWorker.Run(sealingWorkerCtx) })
certWorkerCtx, stopCertWorker := context.WithCancel(context.Background())
certWorker := NewCompletionCertificateWorker(
s.pg,
s.fileManager,
s.certificateGen,
presenterConfigFunc,
s.bucket,
s.logger.Named("completion-certificate-worker"),
)
g.Go(func() error { return certWorker.Run(certWorkerCtx) })
<-ctx.Done()
stopSealingWorker()
stopCertWorker()
return g.Wait()
}
// CreateSignatureRequest contains the parameters for creating a PENDING
// electronic signature.
// CreateSignature creates a PENDING electronic signature row. The conn
// parameter allows the caller to include this insert inside its own
// transaction.
func (s *Service) CreateSignature(
ctx context.Context,
conn pg.Conn,
req *CreateSignatureRequest,
) (*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 {
// Caller provided explicit text; append e-sign process consent
// suffix if not already present.
if !strings.HasSuffix(consentText, coredata.ESignProcessConsentText) {
consentText = consentText + " " + coredata.ESignProcessConsentText
}
}
now := time.Now()
scope := coredata.NewScopeFromObjectID(req.OrganizationID)
sig := &coredata.ElectronicSignature{
ID: gid.New(scope.GetTenantID(), coredata.ElectronicSignatureEntityType),
OrganizationID: req.OrganizationID,
Status: coredata.ElectronicSignatureStatusPending,
DocumentType: req.DocumentType,
FileID: req.FileID,
SignerEmail: req.SignerEmail.String(),
ConsentText: consentText,
SealVersion: 1,
AttemptCount: 0,
MaxAttempts: 10,
CreatedAt: now,
UpdatedAt: now,
}
if err := sig.Insert(ctx, conn, scope); err != nil {
return nil, fmt.Errorf("cannot insert electronic signature: %w", err)
}
return sig, nil
}
func (s *Service) AcceptSignature(ctx context.Context, req *AcceptSignatureRequest) error {
var (
scope = coredata.NewScopeFromObjectID(req.SignatureID)
now = time.Now()
)
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var signature coredata.ElectronicSignature
if err := signature.LoadByID(ctx, tx, scope, req.SignatureID); err != nil {
return fmt.Errorf("cannot load electronic signature: %w", err)
}
if signature.Status != coredata.ElectronicSignatureStatusPending &&
signature.Status != coredata.ElectronicSignatureStatusFailed {
return fmt.Errorf("cannot accept electronic signature in status %s", signature.Status)
}
// If retrying from FAILED, reset attempt tracking.
if signature.Status == coredata.ElectronicSignatureStatusFailed {
signature.AttemptCount = 0
signature.LastError = nil
}
signature.SignerFullName = &req.SignerFullName
signature.SignerIPAddress = &req.SignerIPAddr
signature.SignerUserAgent = &req.SignerUA
signature.SignedAt = &now
signature.Status = coredata.ElectronicSignatureStatusAccepted
signature.UpdatedAt = now
if err := signature.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update signature: %w", err)
}
s.recordEvent(
ctx,
tx,
&RecordEventRequest{
SignatureID: signature.ID,
EventType: coredata.ElectronicSignatureEventTypeSignatureAccepted,
EventSource: coredata.ElectronicSignatureEventSourceServer,
ActorEmail: req.SignerEmail,
ActorIPAddr: req.SignerIPAddr,
ActorUA: req.SignerUA,
},
)
return nil
},
)
}
func (s *Service) RecordEvent(ctx context.Context, req *RecordEventRequest) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
return s.recordEvent(ctx, tx, req)
},
)
}
func (s *Service) recordEvent(ctx context.Context, tx pg.Conn, req *RecordEventRequest) error {
var (
now = time.Now()
scope = coredata.NewScopeFromObjectID(req.SignatureID)
)
event := coredata.ElectronicSignatureEvent{
ID: gid.New(scope.GetTenantID(), coredata.ElectronicSignatureEventEntityType),
ElectronicSignatureID: req.SignatureID,
EventType: req.EventType,
EventSource: req.EventSource,
ActorEmail: req.ActorEmail.String(),
ActorIPAddress: req.ActorIPAddr,
ActorUserAgent: req.ActorUA,
OccurredAt: now,
CreatedAt: now,
}
if err := event.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert signing event: %w", err)
}
return nil
}
func (s *Service) LoadSignatureByID(ctx context.Context, id gid.GID) (*coredata.ElectronicSignature, error) {
var (
scope = coredata.NewScopeFromObjectID(id)
signature = coredata.ElectronicSignature{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := signature.LoadByID(ctx, conn, scope, id); err != nil {
return fmt.Errorf("cannot load electronic signature: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &signature, nil
}
func (s *Service) LoadSignatureByOrgEmailAndDocType(
ctx context.Context,
orgID gid.GID,
email string,
docType coredata.ElectronicSignatureDocumentType,
fileID gid.GID,
) (*coredata.ElectronicSignature, error) {
scope := coredata.NewScopeFromObjectID(orgID)
var sig coredata.ElectronicSignature
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
return sig.LoadByOrgEmailAndDocType(ctx, conn, scope, orgID, email, docType, fileID)
})
if err != nil {
return nil, err
}
return &sig, nil
}
func (s *Service) GenerateCertificateFileURL(
ctx context.Context,
certificateFileID gid.GID,
expiresIn time.Duration,
) (string, error) {
scope := coredata.NewScopeFromObjectID(certificateFileID)
var file coredata.File
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
return file.LoadByID(ctx, conn, scope, certificateFileID)
})
if err != nil {
return "", fmt.Errorf("cannot load certificate file: %w", err)
}
url, err := s.fileManager.GenerateFileUrl(ctx, &file, expiresIn)
if err != nil {
return "", fmt.Errorf("cannot generate certificate file URL: %w", err)
}
return url, nil
}
func (s *Service) LoadEventsBySignatureID(
ctx context.Context,
signatureID gid.GID,
) (coredata.ElectronicSignatureEvents, error) {
var (
scope = coredata.NewScopeFromObjectID(signatureID)
events = coredata.ElectronicSignatureEvents{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := events.LoadBySignatureID(ctx, conn, scope, signatureID); err != nil {
return fmt.Errorf("cannot load events: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return events, nil
}

83
pkg/esign/tsa.go Normal file
View File

@@ -0,0 +1,83 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package esign
import (
"bytes"
"context"
"crypto"
"fmt"
"io"
"net/http"
"github.com/digitorus/timestamp"
"go.gearno.de/kit/httpclient"
)
// TSAClient sends RFC 3161 timestamp requests to a Trusted Timestamp Authority.
type TSAClient struct {
URL string
HTTPClient *http.Client
}
// Timestamp sends an RFC 3161 TimeStampReq via HTTP POST to the TSA.
// The data parameter is the raw bytes to timestamp (typically the seal hex
// string as UTF-8 bytes). CreateRequest internally computes SHA-256(data)
// to build the MessageImprint. Returns the raw DER-encoded TimeStampResp bytes.
func (c *TSAClient) Timestamp(ctx context.Context, data []byte) ([]byte, error) {
tsReq, err := timestamp.CreateRequest(
bytes.NewReader(data),
&timestamp.RequestOptions{
Hash: crypto.SHA256,
Certificates: true,
},
)
if err != nil {
return nil, fmt.Errorf("esign: cannot create timestamp request: %w", err)
}
httpClient := c.HTTPClient
if httpClient == nil {
httpClient = httpclient.DefaultPooledClient()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(tsReq))
if err != nil {
return nil, fmt.Errorf("esign: cannot build TSA HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/timestamp-query")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("esign: TSA request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("esign: TSA returned HTTP %d", resp.StatusCode)
}
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("esign: cannot read TSA response: %w", err)
}
// Validate the response: checks PKIStatus and parses the signed TSTInfo.
if _, err := timestamp.ParseResponse(respBytes); err != nil {
return nil, fmt.Errorf("esign: invalid TSA response: %w", err)
}
return respBytes, nil
}

View File

@@ -76,6 +76,31 @@ func (s *Service) GetFileBase64(
return base64Data, mimeType, nil
}
// GetFileBytes downloads a file from S3 and returns the raw bytes.
func (s *Service) GetFileBytes(
ctx context.Context,
file File,
) ([]byte, error) {
result, err := s.s3Client.GetObject(
ctx,
&s3.GetObjectInput{
Bucket: aws.String(file.GetBucketName()),
Key: aws.String(file.GetObjectKey()),
},
)
if err != nil {
return nil, fmt.Errorf("cannot get file from S3: %w", err)
}
defer func() { _ = result.Body.Close() }()
data, err := io.ReadAll(result.Body)
if err != nil {
return nil, fmt.Errorf("cannot read file data: %w", err)
}
return data, nil
}
func (s *Service) GetFileSize(content io.Reader) (int64, error) {
seeker, ok := content.(io.Seeker)
if !ok {

View File

@@ -24,18 +24,20 @@ import (
"net/smtp"
"time"
"go.probo.inc/probo/pkg/coredata"
"github.com/jhillyerd/enmime"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
)
type (
Mailer struct {
pg *pg.Client
l *log.Logger
cfg Config
interval time.Duration
pg *pg.Client
fileManager *filemanager.Service
l *log.Logger
cfg Config
interval time.Duration
}
Config struct {
@@ -50,7 +52,7 @@ type (
}
)
func NewMailer(pg *pg.Client, l *log.Logger, cfg Config) *Mailer {
func NewMailer(pg *pg.Client, fileManager *filemanager.Service, l *log.Logger, cfg Config) *Mailer {
// Set a default timeout if not provided
if cfg.Timeout == 0 {
cfg.Timeout = 10 * time.Second
@@ -61,7 +63,7 @@ func NewMailer(pg *pg.Client, l *log.Logger, cfg Config) *Mailer {
cfg.Interval = 60 * time.Second
}
return &Mailer{pg: pg, l: l, cfg: cfg, interval: cfg.Interval}
return &Mailer{pg: pg, fileManager: fileManager, l: l, cfg: cfg, interval: cfg.Interval}
}
func (m *Mailer) Run(ctx context.Context) error {
@@ -151,6 +153,11 @@ func (m *Mailer) batchSendEmails(ctx context.Context) error {
return err
}
var attachments coredata.EmailAttachments
if err := attachments.LoadByEmailID(ctx, tx, email.ID); err != nil {
return fmt.Errorf("cannot load email attachments: %w", err)
}
mail := enmime.Builder().
Subject(email.Subject).
From(m.cfg.SenderName, m.cfg.SenderEmail).
@@ -161,6 +168,22 @@ func (m *Mailer) batchSendEmails(ctx context.Context) error {
mail = mail.HTML([]byte(*email.HtmlBody))
}
for _, att := range attachments {
var file coredata.File
if err := file.LoadByID(ctx, tx, coredata.NewNoScope(), att.FileID); err != nil {
return fmt.Errorf("cannot load file record for attachment %s: %w", att.Filename, err)
}
// TODO use reader
data, err := m.fileManager.GetFileBytes(ctx, &file)
if err != nil {
return fmt.Errorf("cannot download attachment %s: %w", att.Filename, err)
}
// TODO [esign] maybe use AddAttachmentWithReader instead?
mail = mail.AddAttachment(data, att.ContentType, att.Filename)
}
envelope, err := mail.Build()
if err != nil {
return fmt.Errorf("cannot build email: %w", err)

View File

@@ -27,6 +27,7 @@ import (
"go.probo.inc/probo/pkg/certmanager"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/filevalidation"
"go.probo.inc/probo/pkg/gid"
@@ -61,6 +62,7 @@ type (
fileManager *filemanager.Service
logger *log.Logger
slack *slack.Service
esign *esign.Service
}
TenantService struct {
@@ -73,6 +75,7 @@ type (
tokenSecret string
agent *agents.Agent
fileManager *filemanager.Service
esign *esign.Service
Frameworks *FrameworkService
Measures *MeasureService
Tasks *TaskService
@@ -128,6 +131,7 @@ func NewService(
logger *log.Logger,
slackService *slack.Service,
iamService *iam.Service,
esignService *esign.Service,
) (*Service, error) {
if bucket == "" {
return nil, fmt.Errorf("bucket is required")
@@ -148,6 +152,7 @@ func NewService(
fileManager: fileManagerService,
logger: logger,
slack: slackService,
esign: esignService,
}
return svc, nil
@@ -164,6 +169,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tokenSecret: s.tokenSecret,
agent: agents.NewAgent(nil, s.agentConfig),
fileManager: s.fileManager,
esign: s.esign,
}
tenantService.Frameworks = &FrameworkService{

View File

@@ -23,6 +23,7 @@ import (
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
@@ -253,6 +254,23 @@ func (s TrustCenterAccessService) Create(
return fmt.Errorf("cannot insert trust center access: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID != nil && s.svc.esign != nil {
_, err := s.svc.esign.CreateSignature(
ctx,
tx,
&esign.CreateSignatureRequest{
OrganizationID: access.OrganizationID,
DocumentType: coredata.ElectronicSignatureDocumentTypeNDA,
FileID: *trustCenter.NonDisclosureAgreementFileID,
SignerEmail: access.Email,
},
)
if err != nil {
return fmt.Errorf("cannot create pending signature: %w", err)
}
}
return nil
},
)

View File

@@ -51,6 +51,7 @@ import (
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/crypto/keys"
"go.probo.inc/probo/pkg/crypto/passwdhash"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/iam"
@@ -69,6 +70,10 @@ type (
cfg config
}
esignConfig struct {
TSAURL string `json:"tsa-url"`
}
config struct {
BaseURL *baseurl.BaseURL `json:"base-url"`
EncryptionKey cipher.EncryptionKey `json:"encryption-key"`
@@ -83,6 +88,7 @@ type (
ChromeDPAddr string `json:"chrome-dp-addr"`
CustomDomains customDomainsConfig `json:"custom-domains"`
SCIMBridge scimBridgeConfig `json:"scim-bridge"`
ESign esignConfig `json:"esign"`
}
trustCenterConfig struct {
@@ -174,6 +180,9 @@ func New() *Implm {
SyncInterval: 60, // 15 minutes
PollInterval: 30, // 30 seconds
},
ESign: esignConfig{
TSAURL: "http://timestamp.digicert.com",
},
},
}
}
@@ -375,6 +384,15 @@ func (impl *Implm) Run(
l.Named("slack"),
)
esignService := esign.NewService(
pgClient,
fileManagerService,
html2pdfConverter,
impl.cfg.ESign.TSAURL,
impl.cfg.AWS.Bucket,
l.Named("esign"),
)
proboService, err := probo.NewService(
ctx,
impl.cfg.EncryptionKey,
@@ -390,6 +408,7 @@ func (impl *Implm) Run(
l.Named("probo"),
slackService,
iamService,
esignService,
)
if err != nil {
return fmt.Errorf("cannot create probo service: %w", err)
@@ -403,6 +422,7 @@ func (impl *Implm) Run(
impl.cfg.EncryptionKey,
impl.cfg.GetSlackSigningSecret(),
iamService,
esignService,
html2pdfConverter,
fileManagerService,
l,
@@ -416,6 +436,7 @@ func (impl *Implm) Run(
Probo: proboService,
IAM: iamService,
Trust: trustService,
ESign: esignService,
Slack: slackService,
ConnectorRegistry: defaultConnectorRegistry,
BaseURL: impl.cfg.BaseURL,
@@ -452,6 +473,7 @@ func (impl *Implm) Run(
mailerCtx, stopMailer := context.WithCancel(context.Background())
mailer := mailer.NewMailer(
pgClient,
fileManagerService,
l,
mailer.Config{
SenderEmail: impl.cfg.Notifications.Mailer.SenderEmail,
@@ -516,6 +538,15 @@ func (impl *Implm) Run(
},
)
esignServiceCtx, stopESignService := context.WithCancel(context.Background())
wg.Go(
func() {
if err := esignService.Run(esignServiceCtx, trustService.EmailPresenterConfigByOrganizationID); err != nil {
cancel(fmt.Errorf("esign service crashed: %w", err))
}
},
)
trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
defer stopTrustCenterServer()
wg.Go(
@@ -528,13 +559,14 @@ func (impl *Implm) Run(
<-ctx.Done()
stopMailer()
stopSlackSender()
stopWebhookSender()
stopExportJobExporter()
stopIAMService()
stopApiServer()
stopTrustCenterServer()
stopWebhookSender()
stopESignService()
stopExportJobExporter()
stopIAMService()
stopMailer()
stopSlackSender()
wg.Wait()

View File

@@ -25,6 +25,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/securecookie"
@@ -44,6 +45,7 @@ type (
Probo *probo.Service
IAM *iam.Service
Trust *trust.Service
ESign *esign.Service
Slack *slack.Service
Cookie securecookie.Config
TokenSecret string
@@ -117,6 +119,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.Logger.Named("trust.v1"),
cfg.IAM,
cfg.Trust,
cfg.ESign,
cfg.Cookie,
cfg.BaseURL,
),
@@ -124,6 +127,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.Logger.Named("console.v1"),
cfg.Probo,
cfg.IAM,
cfg.ESign,
cfg.Cookie,
cfg.TokenSecret,
cfg.ConnectorRegistry,

View File

@@ -18,6 +18,7 @@ import (
"net/http"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authz"
@@ -25,12 +26,13 @@ import (
"go.probo.inc/probo/pkg/server/gqlutils"
)
func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, customDomainCname string, logger *log.Logger) http.Handler {
func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, esignSvc *esign.Service, customDomainCname string, logger *log.Logger) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
authorize: authz.NewAuthorizeFunc(iamSvc, logger),
probo: proboSvc,
iam: iamSvc,
esign: esignSvc,
customDomainCname: customDomainCname,
logger: logger,
},

View File

@@ -31,6 +31,7 @@ import (
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
@@ -47,6 +48,7 @@ type (
authorize authz.AuthorizeFunc
probo *probo.Service
iam *iam.Service
esign *esign.Service
logger *log.Logger
customDomainCname string
}
@@ -56,6 +58,7 @@ func NewMux(
logger *log.Logger,
proboSvc *probo.Service,
iamSvc *iam.Service,
esignSvc *esign.Service,
cookieConfig securecookie.Config,
tokenSecret string,
connectorRegistry *connector.ConnectorRegistry,
@@ -66,7 +69,7 @@ func NewMux(
safeRedirect := &saferedirect.SafeRedirect{AllowedHost: baseURL.Host()}
graphqlHandler := NewGraphQLHandler(iamSvc, proboSvc, customDomainCname, logger)
graphqlHandler := NewGraphQLHandler(iamSvc, proboSvc, esignSvc, customDomainCname, logger)
r.Group(func(r chi.Router) {
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))

View File

@@ -2714,6 +2714,7 @@ type TrustCenterAccess implements Node {
name: String!
state: TrustCenterAccessState!
hasAcceptedNonDisclosureAgreement: Boolean!
ndaSignature: ElectronicSignature @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
lastTokenExpiresAt: Datetime
@@ -5387,3 +5388,149 @@ type CreateCustomDomainPayload {
type DeleteCustomDomainPayload {
deletedCustomDomainId: ID!
}
# Electronic Signature
enum ElectronicSignatureStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatus"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusPending"
)
ACCEPTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusAccepted"
)
PROCESSING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusProcessing"
)
COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusCompleted"
)
FAILED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusFailed"
)
}
enum ElectronicSignatureDocumentType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentType"
) {
NDA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeNDA"
)
DPA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeDPA"
)
MSA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeMSA"
)
SOW
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSOW"
)
SLA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSLA"
)
TOS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeTOS"
)
PRIVACY_POLICY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypePrivacyPolicy"
)
OTHER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeOther"
)
}
enum ElectronicSignatureEventType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventType"
) {
DOCUMENT_VIEWED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeDocumentViewed"
)
CONSENT_GIVEN
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeConsentGiven"
)
FULL_NAME_TYPED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeFullNameTyped"
)
SIGNATURE_ACCEPTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureAccepted"
)
SIGNATURE_COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureCompleted"
)
SEAL_COMPUTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSealComputed"
)
TIMESTAMP_REQUESTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeTimestampRequested"
)
CERTIFICATE_GENERATED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeCertificateGenerated"
)
PROCESSING_ERROR
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeProcessingError"
)
}
enum ElectronicSignatureEventSource
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventSource"
) {
CLIENT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventSourceClient"
)
SERVER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventSourceServer"
)
}
type ElectronicSignature implements Node {
id: ID!
status: ElectronicSignatureStatus!
documentType: ElectronicSignatureDocumentType!
consentText: String!
lastError: String
signedAt: Datetime
certificateFileUrl: String @goField(forceResolver: true)
events: [ElectronicSignatureEvent!]! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type ElectronicSignatureEvent {
id: ID!
eventType: ElectronicSignatureEventType!
eventSource: ElectronicSignatureEventSource!
actorEmail: String!
actorIpAddress: String!
actorUserAgent: String!
occurredAt: Datetime!
createdAt: Datetime!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package types
import (
"go.probo.inc/probo/pkg/coredata"
)
func NewElectronicSignature(es *coredata.ElectronicSignature) *ElectronicSignature {
return &ElectronicSignature{
ID: es.ID,
Status: es.Status,
DocumentType: es.DocumentType,
ConsentText: es.ConsentText,
LastError: es.LastError,
SignedAt: es.SignedAt,
CreatedAt: es.CreatedAt,
UpdatedAt: es.UpdatedAt,
}
}
func NewElectronicSignatureEvent(ev *coredata.ElectronicSignatureEvent) *ElectronicSignatureEvent {
return &ElectronicSignatureEvent{
ID: ev.ID,
EventType: ev.EventType,
EventSource: ev.EventSource,
ActorEmail: ev.ActorEmail,
ActorIPAddress: ev.ActorIPAddress,
ActorUserAgent: ev.ActorUserAgent,
OccurredAt: ev.OccurredAt,
CreatedAt: ev.CreatedAt,
}
}

View File

@@ -1206,6 +1206,33 @@ type DocumentVersionSignatureOrder struct {
Direction page.OrderDirection `json:"direction"`
}
type ElectronicSignature struct {
ID gid.GID `json:"id"`
Status coredata.ElectronicSignatureStatus `json:"status"`
DocumentType coredata.ElectronicSignatureDocumentType `json:"documentType"`
ConsentText string `json:"consentText"`
LastError *string `json:"lastError,omitempty"`
SignedAt *time.Time `json:"signedAt,omitempty"`
CertificateFileURL *string `json:"certificateFileUrl,omitempty"`
Events []*ElectronicSignatureEvent `json:"events"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ElectronicSignature) IsNode() {}
func (this ElectronicSignature) GetID() gid.GID { return this.ID }
type ElectronicSignatureEvent struct {
ID gid.GID `json:"id"`
EventType coredata.ElectronicSignatureEventType `json:"eventType"`
EventSource coredata.ElectronicSignatureEventSource `json:"eventSource"`
ActorEmail string `json:"actorEmail"`
ActorIPAddress string `json:"actorIpAddress"`
ActorUserAgent string `json:"actorUserAgent"`
OccurredAt time.Time `json:"occurredAt"`
CreatedAt time.Time `json:"createdAt"`
}
type Evidence struct {
ID gid.GID `json:"id"`
Size int `json:"size"`
@@ -1847,6 +1874,7 @@ type TrustCenterAccess struct {
Name string `json:"name"`
State coredata.TrustCenterAccessState `json:"state"`
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`
NdaSignature *ElectronicSignature `json:"ndaSignature,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastTokenExpiresAt *time.Time `json:"lastTokenExpiresAt,omitempty"`

View File

@@ -1241,6 +1241,40 @@ func (r *documentVersionSignatureConnectionResolver) TotalCount(ctx context.Cont
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// CertificateFileURL is the resolver for the certificateFileUrl field.
func (r *electronicSignatureResolver) CertificateFileURL(ctx context.Context, obj *types.ElectronicSignature) (*string, error) {
sig, err := r.esign.LoadSignatureByID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load signature: %w", err)
}
if sig.CertificateFileID == nil {
return nil, nil
}
url, err := r.esign.GenerateCertificateFileURL(ctx, *sig.CertificateFileID, 1*time.Hour)
if err != nil {
return nil, fmt.Errorf("cannot generate certificate file URL: %w", err)
}
return &url, nil
}
// Events is the resolver for the events field.
func (r *electronicSignatureResolver) Events(ctx context.Context, obj *types.ElectronicSignature) ([]*types.ElectronicSignatureEvent, error) {
events, err := r.esign.LoadEventsBySignatureID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load signature events: %w", err)
}
result := make([]*types.ElectronicSignatureEvent, len(events))
for i := range events {
result[i] = types.NewElectronicSignatureEvent(events[i])
}
return result, nil
}
// File is the resolver for the file field.
func (r *evidenceResolver) File(ctx context.Context, obj *types.Evidence) (*types.File, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionFileGet); err != nil {
@@ -7785,6 +7819,42 @@ func (r *trustCenterResolver) Permission(ctx context.Context, obj *types.TrustCe
return r.Resolver.Permission(ctx, obj, action)
}
// NdaSignature is the resolver for the ndaSignature field.
func (r *trustCenterAccessResolver) NdaSignature(ctx context.Context, obj *types.TrustCenterAccess) (*types.ElectronicSignature, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionTrustCenterAccessGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
access, err := prb.TrustCenterAccesses.Get(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load trust center access: %w", err)
}
trustCenter, _, err := prb.TrustCenters.Get(ctx, access.TrustCenterID)
if err != nil {
return nil, fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID == nil {
return nil, nil
}
sig, err := r.esign.LoadSignatureByOrgEmailAndDocType(
ctx,
access.OrganizationID,
string(access.Email),
coredata.ElectronicSignatureDocumentTypeNDA,
*trustCenter.NonDisclosureAgreementFileID,
)
if err != nil {
return nil, nil // No signature row — pre-existing access
}
return types.NewElectronicSignature(sig), nil
}
// PendingRequestCount is the resolver for the pendingRequestCount field.
func (r *trustCenterAccessResolver) PendingRequestCount(ctx context.Context, obj *types.TrustCenterAccess) (int, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionTrustCenterAccessGet); err != nil {
@@ -8784,6 +8854,11 @@ func (r *Resolver) DocumentVersionSignatureConnection() schema.DocumentVersionSi
return &documentVersionSignatureConnectionResolver{r}
}
// ElectronicSignature returns schema.ElectronicSignatureResolver implementation.
func (r *Resolver) ElectronicSignature() schema.ElectronicSignatureResolver {
return &electronicSignatureResolver{r}
}
// Evidence returns schema.EvidenceResolver implementation.
func (r *Resolver) Evidence() schema.EvidenceResolver { return &evidenceResolver{r} }
@@ -9029,6 +9104,7 @@ type documentVersionResolver struct{ *Resolver }
type documentVersionConnectionResolver struct{ *Resolver }
type documentVersionSignatureResolver struct{ *Resolver }
type documentVersionSignatureConnectionResolver struct{ *Resolver }
type electronicSignatureResolver struct{ *Resolver }
type evidenceResolver struct{ *Resolver }
type evidenceConnectionResolver struct{ *Resolver }
type fileResolver struct{ *Resolver }

View File

@@ -19,6 +19,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/authn"
@@ -28,11 +29,12 @@ import (
"go.probo.inc/probo/pkg/trust"
)
func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler {
func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, esignSvc *esign.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
iam: iamSvc,
trust: trustSvc,
esign: esignSvc,
logger: logger,
baseURL: baseURL,
sessionCookie: authn.NewCookie(&cookieConfig),

View File

@@ -23,6 +23,7 @@ import (
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
@@ -46,6 +47,7 @@ type (
Resolver struct {
trust *trust.Service
esign *esign.Service
logger *log.Logger
iam *iam.Service
sessionCookie *authn.Cookie
@@ -75,6 +77,7 @@ func NewMux(
logger *log.Logger,
iamSvc *iam.Service,
trustSvc *trust.Service,
esignSvc *esign.Service,
cookieConfig securecookie.Config,
baseURL *baseurl.BaseURL,
) *chi.Mux {
@@ -84,7 +87,7 @@ func NewMux(
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
r.Use(compliancepage.NewMembershipMiddleware(trustSvc, logger))
graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, logger, baseURL, cookieConfig)
graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, esignSvc, logger, baseURL, cookieConfig)
r.Handle("/graphql", graphqlHandler)

View File

@@ -496,6 +496,7 @@ type TrustCenter implements Node {
darkLogoFileUrl: String @goField(forceResolver: true)
ndaFileName: String
ndaFileUrl: String @goField(forceResolver: true)
ndaSignature: ElectronicSignature @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
isViewerMember: Boolean! @goField(forceResolver: true)
hasAcceptedNonDisclosureAgreement: Boolean! @goField(forceResolver: true)
@@ -608,6 +609,143 @@ type AcceptNonDisclosureAgreementPayload {
success: Boolean!
}
# Electronic Signature
enum ElectronicSignatureStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatus"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusPending"
)
ACCEPTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusAccepted"
)
PROCESSING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusProcessing"
)
COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusCompleted"
)
FAILED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusFailed"
)
}
enum ElectronicSignatureDocumentType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentType"
) {
NDA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeNDA"
)
DPA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeDPA"
)
MSA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeMSA"
)
SOW
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSOW"
)
SLA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSLA"
)
TOS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeTOS"
)
PRIVACY_POLICY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypePrivacyPolicy"
)
OTHER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeOther"
)
}
enum ElectronicSignatureEventType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventType"
) {
DOCUMENT_VIEWED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeDocumentViewed"
)
CONSENT_GIVEN
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeConsentGiven"
)
FULL_NAME_TYPED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeFullNameTyped"
)
SIGNATURE_ACCEPTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureAccepted"
)
SIGNATURE_COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureCompleted"
)
SEAL_COMPUTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSealComputed"
)
TIMESTAMP_REQUESTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeTimestampRequested"
)
CERTIFICATE_GENERATED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeCertificateGenerated"
)
PROCESSING_ERROR
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeProcessingError"
)
}
type ElectronicSignature implements Node {
id: ID!
status: ElectronicSignatureStatus!
documentType: ElectronicSignatureDocumentType!
consentText: String!
lastError: String
signedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
}
input AcceptElectronicSignatureInput {
signatureId: ID!
fullName: String!
}
type AcceptElectronicSignaturePayload {
signature: ElectronicSignature!
}
input RecordSigningEventInput {
signatureId: ID!
eventType: ElectronicSignatureEventType!
}
type RecordSigningEventPayload {
success: Boolean!
}
type Query {
viewer: Identity
node(id: ID!): Node!
@@ -647,4 +785,12 @@ type Mutation {
requestTrustCenterFileAccess(
input: RequestTrustCenterFileAccessInput!
): RequestAccessesPayload! @session(required: PRESENT)
acceptElectronicSignature(
input: AcceptElectronicSignatureInput!
): AcceptElectronicSignaturePayload @session(required: PRESENT)
recordSigningEvent(
input: RecordSigningEventInput!
): RecordSigningEventPayload @session(required: PRESENT)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package types
import (
"go.probo.inc/probo/pkg/coredata"
)
func NewElectronicSignature(es *coredata.ElectronicSignature) *ElectronicSignature {
return &ElectronicSignature{
ID: es.ID,
Status: es.Status,
DocumentType: es.DocumentType,
ConsentText: es.ConsentText,
LastError: es.LastError,
SignedAt: es.SignedAt,
CreatedAt: es.CreatedAt,
UpdatedAt: es.UpdatedAt,
}
}

View File

@@ -16,6 +16,15 @@ type Node interface {
GetID() gid.GID
}
type AcceptElectronicSignatureInput struct {
SignatureID gid.GID `json:"signatureId"`
FullName string `json:"fullName"`
}
type AcceptElectronicSignaturePayload struct {
Signature *ElectronicSignature `json:"signature"`
}
type AcceptNonDisclosureAgreementInput struct {
FullName string `json:"fullName"`
}
@@ -64,6 +73,20 @@ type DocumentEdge struct {
Node *Document `json:"node"`
}
type ElectronicSignature struct {
ID gid.GID `json:"id"`
Status coredata.ElectronicSignatureStatus `json:"status"`
DocumentType coredata.ElectronicSignatureDocumentType `json:"documentType"`
ConsentText string `json:"consentText"`
LastError *string `json:"lastError,omitempty"`
SignedAt *time.Time `json:"signedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ElectronicSignature) IsNode() {}
func (this ElectronicSignature) GetID() gid.GID { return this.ID }
type ExportDocumentPDFInput struct {
DocumentID gid.GID `json:"documentId"`
}
@@ -136,6 +159,15 @@ type PageInfo struct {
type Query struct {
}
type RecordSigningEventInput struct {
SignatureID gid.GID `json:"signatureId"`
EventType coredata.ElectronicSignatureEventType `json:"eventType"`
}
type RecordSigningEventPayload struct {
Success bool `json:"success"`
}
type Report struct {
ID gid.GID `json:"id"`
Filename string `json:"filename"`
@@ -178,6 +210,7 @@ type TrustCenter struct {
DarkLogoFileURL *string `json:"darkLogoFileUrl,omitempty"`
NdaFileName *string `json:"ndaFileName,omitempty"`
NdaFileURL *string `json:"ndaFileUrl,omitempty"`
NdaSignature *ElectronicSignature `json:"ndaSignature,omitempty"`
Organization *Organization `json:"organization"`
IsViewerMember bool `json:"isViewerMember"`
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`

View File

@@ -14,6 +14,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/page"
@@ -648,6 +649,71 @@ func (r *mutationResolver) RequestTrustCenterFileAccess(ctx context.Context, inp
}, nil
}
// AcceptElectronicSignature is the resolver for the acceptElectronicSignature field.
func (r *mutationResolver) AcceptElectronicSignature(ctx context.Context, input types.AcceptElectronicSignatureInput) (*types.AcceptElectronicSignaturePayload, error) {
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, gqlutils.Unauthenticatedf(ctx, "unauthenticated")
}
httpReq := gqlutils.HTTPRequestFromContext(ctx)
if _, err := r.iam.AuthService.UpdateIdentity(ctx, identity.ID, input.FullName); err != nil {
var errNotFound *iam.ErrIdentityNotFound
if errors.As(err, &errNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if err := r.esign.AcceptSignature(ctx, &esign.AcceptSignatureRequest{
SignatureID: input.SignatureID,
SignerFullName: input.FullName,
SignerEmail: identity.EmailAddress,
SignerIPAddr: httpReq.RemoteAddr,
SignerUA: httpReq.UserAgent(),
}); err != nil {
r.logger.ErrorCtx(ctx, "cannot accept electronic signature", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
sig, err := r.esign.LoadSignatureByID(ctx, input.SignatureID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load electronic signature", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AcceptElectronicSignaturePayload{
Signature: types.NewElectronicSignature(sig),
}, nil
}
// RecordSigningEvent is the resolver for the recordSigningEvent field.
func (r *mutationResolver) RecordSigningEvent(ctx context.Context, input types.RecordSigningEventInput) (*types.RecordSigningEventPayload, error) {
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, gqlutils.Unauthenticatedf(ctx, "unauthenticated")
}
httpReq := gqlutils.HTTPRequestFromContext(ctx)
if err := r.esign.RecordEvent(ctx, &esign.RecordEventRequest{
SignatureID: input.SignatureID,
EventType: input.EventType,
EventSource: coredata.ElectronicSignatureEventSourceClient,
ActorEmail: identity.EmailAddress,
ActorIPAddr: httpReq.RemoteAddr,
ActorUA: httpReq.UserAgent(),
}); err != nil {
r.logger.ErrorCtx(ctx, "cannot record signing event", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.RecordSigningEventPayload{Success: true}, nil
}
// LogoURL is the resolver for the logoUrl field.
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
trustService := r.TrustService(ctx, obj.ID.TenantID())
@@ -868,6 +934,32 @@ func (r *trustCenterResolver) NdaFileURL(ctx context.Context, obj *types.TrustCe
return &fileURL, nil
}
// NdaSignature is the resolver for the ndaSignature field.
func (r *trustCenterResolver) NdaSignature(ctx context.Context, obj *types.TrustCenter) (*types.ElectronicSignature, error) {
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, nil
}
trustCenter := compliancepage.CompliancePageFromContext(ctx)
if trustCenter == nil || trustCenter.NonDisclosureAgreementFileID == nil {
return nil, nil
}
sig, err := r.esign.LoadSignatureByOrgEmailAndDocType(
ctx,
trustCenter.OrganizationID,
identity.EmailAddress.String(),
coredata.ElectronicSignatureDocumentTypeNDA,
*trustCenter.NonDisclosureAgreementFileID,
)
if err != nil {
return nil, nil
}
return types.NewElectronicSignature(sig), nil
}
// Organization is the resolver for the organization field.
func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.TrustCenter) (*types.Organization, error) {
return obj.Organization, nil

View File

@@ -25,6 +25,7 @@ import (
"go.probo.inc/probo/pkg/agents"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/securecookie"
@@ -43,6 +44,7 @@ type Config struct {
Probo *probo.Service
IAM *iam.Service
Trust *trust.Service
ESign *esign.Service
Slack *slack.Service
Cookie securecookie.Config
TokenSecret string
@@ -70,6 +72,7 @@ func NewServer(cfg Config) (*Server, error) {
Probo: cfg.Probo,
IAM: cfg.IAM,
Trust: cfg.Trust,
ESign: cfg.ESign,
Slack: cfg.Slack,
Cookie: cfg.Cookie,
TokenSecret: cfg.TokenSecret,

View File

@@ -22,8 +22,10 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
@@ -43,6 +45,7 @@ type (
slackSigningSecret string
baseURL string
iam *iam.Service
esign *esign.Service
html2pdfConverter *html2pdf.Converter
fileManager *filemanager.Service
logger *log.Logger
@@ -58,6 +61,7 @@ type (
encryptionKey cipher.EncryptionKey
baseURL string
iam *iam.Service
esign *esign.Service
html2pdfConverter *html2pdf.Converter
fileManager *filemanager.Service
logger *log.Logger
@@ -83,6 +87,7 @@ func NewService(
encryptionKey cipher.EncryptionKey,
slackSigningSecret string,
iam *iam.Service,
esignSvc *esign.Service,
html2pdfConverter *html2pdf.Converter,
fileManagerService *filemanager.Service,
logger *log.Logger,
@@ -96,6 +101,7 @@ func NewService(
slackSigningSecret: slackSigningSecret,
baseURL: baseURL,
iam: iam,
esign: esignSvc,
html2pdfConverter: html2pdfConverter,
fileManager: fileManagerService,
logger: logger,
@@ -113,6 +119,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
encryptionKey: s.encryptionKey,
baseURL: s.baseURL,
iam: s.iam,
esign: s.esign,
html2pdfConverter: s.html2pdfConverter,
fileManager: s.fileManager,
logger: s.logger,
@@ -253,6 +260,21 @@ func (s *Service) GetCustomDomainByOrganizationID(ctx context.Context, organizat
return customDomain, err
}
// EmailPresenterConfigByOrganizationID resolves the emails.PresenterConfig for
// the trust center that belongs to the given organization. This is used by the
// esign certificate worker which needs per-org branding at render time.
func (s *Service) EmailPresenterConfigByOrganizationID(ctx context.Context, orgID gid.GID) (emails.PresenterConfig, error) {
var trustCenter coredata.TrustCenter
scope := coredata.NewScopeFromObjectID(orgID)
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
return trustCenter.LoadByOrganizationID(ctx, conn, scope, orgID)
})
if err != nil {
return emails.PresenterConfig{}, fmt.Errorf("cannot load trust center for org %s: %w", orgID, err)
}
return s.WithTenant(orgID.TenantID()).TrustCenters.EmailPresenterConfig(ctx, trustCenter.ID)
}
func (s *Service) GetMembershipByCompliancePageIDAndEmail(ctx context.Context, compliancePageID gid.GID, email mail.Addr) (*coredata.TrustCenterAccess, error) {
membership := &coredata.TrustCenterAccess{}

View File

@@ -25,6 +25,7 @@ import (
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mail"
@@ -157,6 +158,23 @@ func (s TrustCenterAccessService) Request(
if err := access.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert trust center access: %w", err)
}
// Create PENDING electronic signature when NDA is configured.
if trustCenter.NonDisclosureAgreementFileID != nil && s.svc.esign != nil {
_, err := s.svc.esign.CreateSignature(
ctx,
tx,
&esign.CreateSignatureRequest{
OrganizationID: access.OrganizationID,
DocumentType: coredata.ElectronicSignatureDocumentTypeNDA,
FileID: *trustCenter.NonDisclosureAgreementFileID,
SignerEmail: access.Email,
},
)
if err != nil {
return fmt.Errorf("cannot create pending signature: %w", err)
}
}
}
var existingAccesses coredata.TrustCenterDocumentAccesses
@@ -226,21 +244,50 @@ func (s TrustCenterAccessService) Request(
}
func (s TrustCenterAccessService) HasAcceptedNonDisclosureAgreement(ctx context.Context, trustCenterID gid.GID, email mail.Addr) (bool, error) {
access := &coredata.TrustCenterAccess{}
var access coredata.TrustCenterAccess
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
err := access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, trustCenterID, email)
if err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
return nil
return access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, trustCenterID, email)
})
if err != nil {
return false, nil
}
return access.HasAcceptedNonDisclosureAgreement, nil
// Legacy path: check the old boolean field.
if access.HasAcceptedNonDisclosureAgreement {
return true, nil
}
// New path: check for an electronic signature in ACCEPTED or later status.
if s.svc.esign != nil {
trustCenter := &coredata.TrustCenter{}
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return trustCenter.LoadByID(ctx, conn, s.svc.scope, trustCenterID)
})
if err != nil {
return false, nil
}
if trustCenter.NonDisclosureAgreementFileID != nil {
sig, err := s.svc.esign.LoadSignatureByOrgEmailAndDocType(
ctx,
trustCenter.OrganizationID,
email.String(),
coredata.ElectronicSignatureDocumentTypeNDA,
*trustCenter.NonDisclosureAgreementFileID,
)
if err == nil {
switch sig.Status {
case coredata.ElectronicSignatureStatusAccepted,
coredata.ElectronicSignatureStatusProcessing,
coredata.ElectronicSignatureStatusCompleted:
return true, nil
}
}
}
}
return false, nil
}
type AcceptNDARequest struct {