Add electronic signature
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -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, you’ll 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -2,9 +2,8 @@ import { useFavicon, useSystemTheme } from "@probo/hooks";
|
|||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Logo, TabLink, Tabs } from "@probo/ui";
|
import { Logo, TabLink, Tabs } from "@probo/ui";
|
||||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
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 { OrganizationSidebar } from "#/components/OrganizationSidebar";
|
||||||
import { TrustCenterProvider } from "#/providers/TrustCenterProvider";
|
import { TrustCenterProvider } from "#/providers/TrustCenterProvider";
|
||||||
import { Viewer } from "#/providers/Viewer";
|
import { Viewer } from "#/providers/Viewer";
|
||||||
@@ -28,20 +27,20 @@ export function MainLayout(props: Props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showNDADialog
|
const ndaSig = trustCenter.ndaSignature;
|
||||||
|
const hasPendingNDA
|
||||||
= trustCenter.isViewerMember
|
= trustCenter.isViewerMember
|
||||||
&& !trustCenter.hasAcceptedNonDisclosureAgreement
|
&& trustCenter.ndaFileUrl
|
||||||
&& trustCenter.ndaFileUrl;
|
&& ndaSig
|
||||||
|
&& ndaSig.status !== "COMPLETED";
|
||||||
|
|
||||||
|
if (hasPendingNDA) {
|
||||||
|
return <Navigate to="/nda" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Viewer value={data.viewer}>
|
<Viewer value={data.viewer}>
|
||||||
<TrustCenterProvider trustCenter={trustCenter}>
|
<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 ">
|
<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} />
|
<OrganizationSidebar trustCenter={trustCenter} />
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
332
apps/trust/src/pages/NDAPage.tsx
Normal file
332
apps/trust/src/pages/NDAPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/trust/src/pages/NDAPageLoader.tsx
Normal file
34
apps/trust/src/pages/NDAPageLoader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/trust/src/pages/__generated__/NDAPageAcceptElectronicSignatureMutation.graphql.ts
generated
Normal file
115
apps/trust/src/pages/__generated__/NDAPageAcceptElectronicSignatureMutation.graphql.ts
generated
Normal 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;
|
||||||
103
apps/trust/src/pages/__generated__/NDAPageFragment.graphql.ts
generated
Normal file
103
apps/trust/src/pages/__generated__/NDAPageFragment.graphql.ts
generated
Normal 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;
|
||||||
236
apps/trust/src/pages/__generated__/NDAPageQuery.graphql.ts
generated
Normal file
236
apps/trust/src/pages/__generated__/NDAPageQuery.graphql.ts
generated
Normal 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;
|
||||||
94
apps/trust/src/pages/__generated__/NDAPageRecordSigningEventMutation.graphql.ts
generated
Normal file
94
apps/trust/src/pages/__generated__/NDAPageRecordSigningEventMutation.graphql.ts
generated
Normal 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;
|
||||||
155
apps/trust/src/pages/__generated__/NDAPageRefetchQuery.graphql.ts
generated
Normal file
155
apps/trust/src/pages/__generated__/NDAPageRefetchQuery.graphql.ts
generated
Normal 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;
|
||||||
@@ -11,11 +11,13 @@ export const currentTrustGraphQuery = graphql`
|
|||||||
id
|
id
|
||||||
slug
|
slug
|
||||||
isViewerMember
|
isViewerMember
|
||||||
hasAcceptedNonDisclosureAgreement
|
|
||||||
logoFileUrl
|
logoFileUrl
|
||||||
darkLogoFileUrl
|
darkLogoFileUrl
|
||||||
ndaFileName
|
ndaFileName
|
||||||
ndaFileUrl
|
ndaFileUrl
|
||||||
|
ndaSignature {
|
||||||
|
status
|
||||||
|
}
|
||||||
organization {
|
organization {
|
||||||
name
|
name
|
||||||
description
|
description
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<006540288cfca33589264e1f596e1028>>
|
* @generated SignedSource<<4bf04c5599ef88c6104919907fb182dd>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { ConcreteRequest } from 'relay-runtime';
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
import { FragmentRefs } 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$variables = Record<PropertyKey, never>;
|
||||||
export type TrustGraphCurrentQuery$data = {
|
export type TrustGraphCurrentQuery$data = {
|
||||||
readonly currentTrustCenter: {
|
readonly currentTrustCenter: {
|
||||||
@@ -22,12 +23,14 @@ export type TrustGraphCurrentQuery$data = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
readonly darkLogoFileUrl: string | null | undefined;
|
readonly darkLogoFileUrl: string | null | undefined;
|
||||||
readonly hasAcceptedNonDisclosureAgreement: boolean;
|
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly isViewerMember: boolean;
|
readonly isViewerMember: boolean;
|
||||||
readonly logoFileUrl: string | null | undefined;
|
readonly logoFileUrl: string | null | undefined;
|
||||||
readonly ndaFileName: string | null | undefined;
|
readonly ndaFileName: string | null | undefined;
|
||||||
readonly ndaFileUrl: string | null | undefined;
|
readonly ndaFileUrl: string | null | undefined;
|
||||||
|
readonly ndaSignature: {
|
||||||
|
readonly status: ElectronicSignatureStatus;
|
||||||
|
} | null | undefined;
|
||||||
readonly organization: {
|
readonly organization: {
|
||||||
readonly description: string | null | undefined;
|
readonly description: string | null | undefined;
|
||||||
readonly email: string | null | undefined;
|
readonly email: string | null | undefined;
|
||||||
@@ -91,35 +94,35 @@ v5 = {
|
|||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "hasAcceptedNonDisclosureAgreement",
|
"name": "logoFileUrl",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v6 = {
|
v6 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "logoFileUrl",
|
"name": "darkLogoFileUrl",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v7 = {
|
v7 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "darkLogoFileUrl",
|
"name": "ndaFileName",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v8 = {
|
v8 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "ndaFileName",
|
"name": "ndaFileUrl",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v9 = {
|
v9 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "ndaFileUrl",
|
"name": "status",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v10 = {
|
v10 = {
|
||||||
@@ -237,7 +240,18 @@ return {
|
|||||||
(v6/*: any*/),
|
(v6/*: any*/),
|
||||||
(v7/*: any*/),
|
(v7/*: any*/),
|
||||||
(v8/*: any*/),
|
(v8/*: any*/),
|
||||||
(v9/*: any*/),
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "ElectronicSignature",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "ndaSignature",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
(v9/*: any*/)
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -341,7 +355,19 @@ return {
|
|||||||
(v6/*: any*/),
|
(v6/*: any*/),
|
||||||
(v7/*: any*/),
|
(v7/*: any*/),
|
||||||
(v8/*: 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,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -641,16 +667,16 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "af3118fae0bbc255713aae845ab83032",
|
"cacheID": "5756e245b9ad1dc00d25c505e4764a62",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "TrustGraphCurrentQuery",
|
"name": "TrustGraphCurrentQuery",
|
||||||
"operationKind": "query",
|
"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;
|
export default node;
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const routes = [
|
|||||||
Component: Fragment,
|
Component: Fragment,
|
||||||
ErrorBoundary: ErrorBoundary,
|
ErrorBoundary: ErrorBoundary,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/nda",
|
||||||
|
Component: lazy(() => import("#/pages/NDAPageLoader")),
|
||||||
|
ErrorBoundary: ErrorBoundary,
|
||||||
|
},
|
||||||
// Custom domain routes (subdomain-based)
|
// Custom domain routes (subdomain-based)
|
||||||
{
|
{
|
||||||
path: "/overview",
|
path: "/overview",
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -13,9 +13,11 @@ require (
|
|||||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||||
github.com/chromedp/chromedp v0.14.2
|
github.com/chromedp/chromedp v0.14.2
|
||||||
github.com/crewjam/saml v0.5.1
|
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/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8
|
||||||
github.com/go-chi/chi/v5 v5.2.4
|
github.com/go-chi/chi/v5 v5.2.4
|
||||||
github.com/go-chi/cors v1.2.2
|
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/jackc/pgx/v5 v5.8.0
|
||||||
github.com/jhillyerd/enmime v1.3.0
|
github.com/jhillyerd/enmime v1.3.0
|
||||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||||
@@ -29,7 +31,7 @@ require (
|
|||||||
github.com/yuin/goldmark v1.7.16
|
github.com/yuin/goldmark v1.7.16
|
||||||
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712
|
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712
|
||||||
go.gearno.de/kit v0.1.1
|
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 v1.39.0
|
||||||
go.opentelemetry.io/otel/trace v1.39.0
|
go.opentelemetry.io/otel/trace v1.39.0
|
||||||
go.probo.inc/mcpgen v0.0.0-20251124210642-41a5174eb92f
|
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/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/di-wu/parser v0.3.0 // indirect
|
github.com/di-wu/parser v0.3.0 // indirect
|
||||||
github.com/di-wu/xsd-datetime v1.0.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/dnephin/pflag v1.0.7 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // 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/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // 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/enterprise-certificate-proxy v0.3.9 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -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/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 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI=
|
||||||
github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww=
|
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 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
|
||||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
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=
|
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/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 h1:E3Cr9NB8Ry2EsvEG/1eHr7kplP3tEjTf5d56dTX64VQ=
|
||||||
go.gearno.de/x/panicf v0.1.1/go.mod h1:VnB8oF0UefMZcYeD4v+Wk4U5Z1uza7PHLlhT2CbNEbU=
|
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-20260216110753-a700c951377c h1:rIVWwnNxHYu9aZhHkptXlNYTBJbY4ccaIAYjztVeaDc=
|
||||||
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/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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ const (
|
|||||||
subjectTrustCenterAccess = "Compliance Page Access Invitation - %s"
|
subjectTrustCenterAccess = "Compliance Page Access Invitation - %s"
|
||||||
subjectTrustCenterDocumentAccessRejected = "Compliance Page Document Access Rejected - %s"
|
subjectTrustCenterDocumentAccessRejected = "Compliance Page Document Access Rejected - %s"
|
||||||
subjectMagicLink = "Connect to %s"
|
subjectMagicLink = "Connect to %s"
|
||||||
|
subjectElectronicSignatureCertificate = "Your signed %s - Certificate of Completion"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -238,6 +239,8 @@ var (
|
|||||||
trustCenterDocumentAccessRejectedTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-document-access-rejected.txt.tmpl"))
|
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"))
|
magicLinkHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/magic-link.html.tmpl"))
|
||||||
magicLinkTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/magic-link.txt.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) {
|
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
|
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) {
|
func renderEmail(textTemplate *texttemplate.Template, htmlTemplate *htmltemplate.Template, data any) (textBody string, htmlBody *string, err error) {
|
||||||
var textBuf bytes.Buffer
|
var textBuf bytes.Buffer
|
||||||
if err := textTemplate.Execute(&textBuf, data); err != nil {
|
if err := textTemplate.Execute(&textBuf, data); err != nil {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Invitation from "../src/Invitation";
|
|||||||
import PasswordReset from "../src/PasswordReset";
|
import PasswordReset from "../src/PasswordReset";
|
||||||
import TrustCenterAccess from "../src/TrustCenterAccess";
|
import TrustCenterAccess from "../src/TrustCenterAccess";
|
||||||
import TrustCenterDocumentAccessRejected from "../src/TrustCenterDocumentAccessRejected";
|
import TrustCenterDocumentAccessRejected from "../src/TrustCenterDocumentAccessRejected";
|
||||||
|
import ElectronicSignatureCertificate from "../src/ElectronicSignatureCertificate";
|
||||||
import MagicLink from "../src/MagicLink";
|
import MagicLink from "../src/MagicLink";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -59,6 +60,10 @@ const templates: TemplateConfig[] = [
|
|||||||
name: "magic-link",
|
name: "magic-link",
|
||||||
render: () => MagicLink(),
|
render: () => MagicLink(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "electronic-signature-certificate",
|
||||||
|
render: () => ElectronicSignatureCertificate(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function build() {
|
async function build() {
|
||||||
|
|||||||
31
packages/emails/src/ElectronicSignatureCertificate.tsx
Normal file
31
packages/emails/src/ElectronicSignatureCertificate.tsx
Normal 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;
|
||||||
@@ -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
|
||||||
424
pkg/coredata/electronic_signature.go
Normal file
424
pkg/coredata/electronic_signature.go
Normal 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
|
||||||
|
}
|
||||||
145
pkg/coredata/electronic_signature_document_type.go
Normal file
145
pkg/coredata/electronic_signature_document_type.go
Normal 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
|
||||||
|
}
|
||||||
117
pkg/coredata/electronic_signature_event.go
Normal file
117
pkg/coredata/electronic_signature_event.go
Normal 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
|
||||||
|
}
|
||||||
65
pkg/coredata/electronic_signature_event_source.go
Normal file
65
pkg/coredata/electronic_signature_event_source.go
Normal 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
|
||||||
|
}
|
||||||
86
pkg/coredata/electronic_signature_event_type.go
Normal file
86
pkg/coredata/electronic_signature_event_type.go
Normal 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
|
||||||
|
}
|
||||||
74
pkg/coredata/electronic_signature_status.go
Normal file
74
pkg/coredata/electronic_signature_status.go
Normal 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
|
||||||
|
}
|
||||||
105
pkg/coredata/email_attachment.go
Normal file
105
pkg/coredata/email_attachment.go
Normal 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
|
||||||
|
}
|
||||||
@@ -82,6 +82,9 @@ const (
|
|||||||
WebhookSubscriptionEntityType uint16 = 56
|
WebhookSubscriptionEntityType uint16 = 56
|
||||||
WebhookDataEntityType uint16 = 57
|
WebhookDataEntityType uint16 = 57
|
||||||
WebhookEventEntityType uint16 = 58
|
WebhookEventEntityType uint16 = 58
|
||||||
|
ElectronicSignatureEntityType uint16 = 59
|
||||||
|
ElectronicSignatureEventEntityType uint16 = 60
|
||||||
|
EmailAttachmentEntityType uint16 = 61
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||||
@@ -200,6 +203,12 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
|||||||
return &WebhookData{ID: id}, true
|
return &WebhookData{ID: id}, true
|
||||||
case WebhookEventEntityType:
|
case WebhookEventEntityType:
|
||||||
return &WebhookEvent{ID: id}, true
|
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:
|
default:
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
94
pkg/coredata/migrations/20260213T150000Z.sql
Normal file
94
pkg/coredata/migrations/20260213T150000Z.sql
Normal 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
25
pkg/crypto/hash/hash.go
Normal 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
167
pkg/esign/certgen.go
Normal 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
|
||||||
|
}
|
||||||
520
pkg/esign/certificate.html.tmpl
Normal file
520
pkg/esign/certificate.html.tmpl
Normal 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">
|
||||||
|
|
||||||
|
</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>
|
||||||
375
pkg/esign/completion_certificate_worker.go
Normal file
375
pkg/esign/completion_certificate_worker.go
Normal 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
344
pkg/esign/sealing_worker.go
Normal 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
352
pkg/esign/service.go
Normal 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
83
pkg/esign/tsa.go
Normal 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),
|
||||||
|
×tamp.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
|
||||||
|
}
|
||||||
@@ -76,6 +76,31 @@ func (s *Service) GetFileBase64(
|
|||||||
return base64Data, mimeType, nil
|
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) {
|
func (s *Service) GetFileSize(content io.Reader) (int64, error) {
|
||||||
seeker, ok := content.(io.Seeker)
|
seeker, ok := content.(io.Seeker)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -24,18 +24,20 @@ import (
|
|||||||
"net/smtp"
|
"net/smtp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Mailer struct {
|
Mailer struct {
|
||||||
pg *pg.Client
|
pg *pg.Client
|
||||||
l *log.Logger
|
fileManager *filemanager.Service
|
||||||
cfg Config
|
l *log.Logger
|
||||||
interval time.Duration
|
cfg Config
|
||||||
|
interval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
Config struct {
|
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
|
// Set a default timeout if not provided
|
||||||
if cfg.Timeout == 0 {
|
if cfg.Timeout == 0 {
|
||||||
cfg.Timeout = 10 * time.Second
|
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
|
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 {
|
func (m *Mailer) Run(ctx context.Context) error {
|
||||||
@@ -151,6 +153,11 @@ func (m *Mailer) batchSendEmails(ctx context.Context) error {
|
|||||||
return err
|
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().
|
mail := enmime.Builder().
|
||||||
Subject(email.Subject).
|
Subject(email.Subject).
|
||||||
From(m.cfg.SenderName, m.cfg.SenderEmail).
|
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))
|
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()
|
envelope, err := mail.Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot build email: %w", err)
|
return fmt.Errorf("cannot build email: %w", err)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/certmanager"
|
"go.probo.inc/probo/pkg/certmanager"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/filevalidation"
|
"go.probo.inc/probo/pkg/filevalidation"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
@@ -61,6 +62,7 @@ type (
|
|||||||
fileManager *filemanager.Service
|
fileManager *filemanager.Service
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
slack *slack.Service
|
slack *slack.Service
|
||||||
|
esign *esign.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
TenantService struct {
|
TenantService struct {
|
||||||
@@ -73,6 +75,7 @@ type (
|
|||||||
tokenSecret string
|
tokenSecret string
|
||||||
agent *agents.Agent
|
agent *agents.Agent
|
||||||
fileManager *filemanager.Service
|
fileManager *filemanager.Service
|
||||||
|
esign *esign.Service
|
||||||
Frameworks *FrameworkService
|
Frameworks *FrameworkService
|
||||||
Measures *MeasureService
|
Measures *MeasureService
|
||||||
Tasks *TaskService
|
Tasks *TaskService
|
||||||
@@ -128,6 +131,7 @@ func NewService(
|
|||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
slackService *slack.Service,
|
slackService *slack.Service,
|
||||||
iamService *iam.Service,
|
iamService *iam.Service,
|
||||||
|
esignService *esign.Service,
|
||||||
) (*Service, error) {
|
) (*Service, error) {
|
||||||
if bucket == "" {
|
if bucket == "" {
|
||||||
return nil, fmt.Errorf("bucket is required")
|
return nil, fmt.Errorf("bucket is required")
|
||||||
@@ -148,6 +152,7 @@ func NewService(
|
|||||||
fileManager: fileManagerService,
|
fileManager: fileManagerService,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
slack: slackService,
|
slack: slackService,
|
||||||
|
esign: esignService,
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc, nil
|
return svc, nil
|
||||||
@@ -164,6 +169,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
|||||||
tokenSecret: s.tokenSecret,
|
tokenSecret: s.tokenSecret,
|
||||||
agent: agents.NewAgent(nil, s.agentConfig),
|
agent: agents.NewAgent(nil, s.agentConfig),
|
||||||
fileManager: s.fileManager,
|
fileManager: s.fileManager,
|
||||||
|
esign: s.esign,
|
||||||
}
|
}
|
||||||
|
|
||||||
tenantService.Frameworks = &FrameworkService{
|
tenantService.Frameworks = &FrameworkService{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/packages/emails"
|
"go.probo.inc/probo/packages/emails"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/mail"
|
"go.probo.inc/probo/pkg/mail"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
@@ -253,6 +254,23 @@ func (s TrustCenterAccessService) Create(
|
|||||||
return fmt.Errorf("cannot insert trust center access: %w", err)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||||
"go.probo.inc/probo/pkg/crypto/keys"
|
"go.probo.inc/probo/pkg/crypto/keys"
|
||||||
"go.probo.inc/probo/pkg/crypto/passwdhash"
|
"go.probo.inc/probo/pkg/crypto/passwdhash"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/html2pdf"
|
"go.probo.inc/probo/pkg/html2pdf"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
@@ -69,6 +70,10 @@ type (
|
|||||||
cfg config
|
cfg config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
esignConfig struct {
|
||||||
|
TSAURL string `json:"tsa-url"`
|
||||||
|
}
|
||||||
|
|
||||||
config struct {
|
config struct {
|
||||||
BaseURL *baseurl.BaseURL `json:"base-url"`
|
BaseURL *baseurl.BaseURL `json:"base-url"`
|
||||||
EncryptionKey cipher.EncryptionKey `json:"encryption-key"`
|
EncryptionKey cipher.EncryptionKey `json:"encryption-key"`
|
||||||
@@ -83,6 +88,7 @@ type (
|
|||||||
ChromeDPAddr string `json:"chrome-dp-addr"`
|
ChromeDPAddr string `json:"chrome-dp-addr"`
|
||||||
CustomDomains customDomainsConfig `json:"custom-domains"`
|
CustomDomains customDomainsConfig `json:"custom-domains"`
|
||||||
SCIMBridge scimBridgeConfig `json:"scim-bridge"`
|
SCIMBridge scimBridgeConfig `json:"scim-bridge"`
|
||||||
|
ESign esignConfig `json:"esign"`
|
||||||
}
|
}
|
||||||
|
|
||||||
trustCenterConfig struct {
|
trustCenterConfig struct {
|
||||||
@@ -174,6 +180,9 @@ func New() *Implm {
|
|||||||
SyncInterval: 60, // 15 minutes
|
SyncInterval: 60, // 15 minutes
|
||||||
PollInterval: 30, // 30 seconds
|
PollInterval: 30, // 30 seconds
|
||||||
},
|
},
|
||||||
|
ESign: esignConfig{
|
||||||
|
TSAURL: "http://timestamp.digicert.com",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,6 +384,15 @@ func (impl *Implm) Run(
|
|||||||
l.Named("slack"),
|
l.Named("slack"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
esignService := esign.NewService(
|
||||||
|
pgClient,
|
||||||
|
fileManagerService,
|
||||||
|
html2pdfConverter,
|
||||||
|
impl.cfg.ESign.TSAURL,
|
||||||
|
impl.cfg.AWS.Bucket,
|
||||||
|
l.Named("esign"),
|
||||||
|
)
|
||||||
|
|
||||||
proboService, err := probo.NewService(
|
proboService, err := probo.NewService(
|
||||||
ctx,
|
ctx,
|
||||||
impl.cfg.EncryptionKey,
|
impl.cfg.EncryptionKey,
|
||||||
@@ -390,6 +408,7 @@ func (impl *Implm) Run(
|
|||||||
l.Named("probo"),
|
l.Named("probo"),
|
||||||
slackService,
|
slackService,
|
||||||
iamService,
|
iamService,
|
||||||
|
esignService,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot create probo service: %w", err)
|
return fmt.Errorf("cannot create probo service: %w", err)
|
||||||
@@ -403,6 +422,7 @@ func (impl *Implm) Run(
|
|||||||
impl.cfg.EncryptionKey,
|
impl.cfg.EncryptionKey,
|
||||||
impl.cfg.GetSlackSigningSecret(),
|
impl.cfg.GetSlackSigningSecret(),
|
||||||
iamService,
|
iamService,
|
||||||
|
esignService,
|
||||||
html2pdfConverter,
|
html2pdfConverter,
|
||||||
fileManagerService,
|
fileManagerService,
|
||||||
l,
|
l,
|
||||||
@@ -416,6 +436,7 @@ func (impl *Implm) Run(
|
|||||||
Probo: proboService,
|
Probo: proboService,
|
||||||
IAM: iamService,
|
IAM: iamService,
|
||||||
Trust: trustService,
|
Trust: trustService,
|
||||||
|
ESign: esignService,
|
||||||
Slack: slackService,
|
Slack: slackService,
|
||||||
ConnectorRegistry: defaultConnectorRegistry,
|
ConnectorRegistry: defaultConnectorRegistry,
|
||||||
BaseURL: impl.cfg.BaseURL,
|
BaseURL: impl.cfg.BaseURL,
|
||||||
@@ -452,6 +473,7 @@ func (impl *Implm) Run(
|
|||||||
mailerCtx, stopMailer := context.WithCancel(context.Background())
|
mailerCtx, stopMailer := context.WithCancel(context.Background())
|
||||||
mailer := mailer.NewMailer(
|
mailer := mailer.NewMailer(
|
||||||
pgClient,
|
pgClient,
|
||||||
|
fileManagerService,
|
||||||
l,
|
l,
|
||||||
mailer.Config{
|
mailer.Config{
|
||||||
SenderEmail: impl.cfg.Notifications.Mailer.SenderEmail,
|
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())
|
trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
|
||||||
defer stopTrustCenterServer()
|
defer stopTrustCenterServer()
|
||||||
wg.Go(
|
wg.Go(
|
||||||
@@ -528,13 +559,14 @@ func (impl *Implm) Run(
|
|||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
stopMailer()
|
|
||||||
stopSlackSender()
|
|
||||||
stopWebhookSender()
|
|
||||||
stopExportJobExporter()
|
|
||||||
stopIAMService()
|
|
||||||
stopApiServer()
|
stopApiServer()
|
||||||
stopTrustCenterServer()
|
stopTrustCenterServer()
|
||||||
|
stopWebhookSender()
|
||||||
|
stopESignService()
|
||||||
|
stopExportJobExporter()
|
||||||
|
stopIAMService()
|
||||||
|
stopMailer()
|
||||||
|
stopSlackSender()
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/securecookie"
|
"go.probo.inc/probo/pkg/securecookie"
|
||||||
@@ -44,6 +45,7 @@ type (
|
|||||||
Probo *probo.Service
|
Probo *probo.Service
|
||||||
IAM *iam.Service
|
IAM *iam.Service
|
||||||
Trust *trust.Service
|
Trust *trust.Service
|
||||||
|
ESign *esign.Service
|
||||||
Slack *slack.Service
|
Slack *slack.Service
|
||||||
Cookie securecookie.Config
|
Cookie securecookie.Config
|
||||||
TokenSecret string
|
TokenSecret string
|
||||||
@@ -117,6 +119,7 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
cfg.Logger.Named("trust.v1"),
|
cfg.Logger.Named("trust.v1"),
|
||||||
cfg.IAM,
|
cfg.IAM,
|
||||||
cfg.Trust,
|
cfg.Trust,
|
||||||
|
cfg.ESign,
|
||||||
cfg.Cookie,
|
cfg.Cookie,
|
||||||
cfg.BaseURL,
|
cfg.BaseURL,
|
||||||
),
|
),
|
||||||
@@ -124,6 +127,7 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
cfg.Logger.Named("console.v1"),
|
cfg.Logger.Named("console.v1"),
|
||||||
cfg.Probo,
|
cfg.Probo,
|
||||||
cfg.IAM,
|
cfg.IAM,
|
||||||
|
cfg.ESign,
|
||||||
cfg.Cookie,
|
cfg.Cookie,
|
||||||
cfg.TokenSecret,
|
cfg.TokenSecret,
|
||||||
cfg.ConnectorRegistry,
|
cfg.ConnectorRegistry,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/authz"
|
"go.probo.inc/probo/pkg/server/api/authz"
|
||||||
@@ -25,12 +26,13 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
"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{
|
config := schema.Config{
|
||||||
Resolvers: &Resolver{
|
Resolvers: &Resolver{
|
||||||
authorize: authz.NewAuthorizeFunc(iamSvc, logger),
|
authorize: authz.NewAuthorizeFunc(iamSvc, logger),
|
||||||
probo: proboSvc,
|
probo: proboSvc,
|
||||||
iam: iamSvc,
|
iam: iamSvc,
|
||||||
|
esign: esignSvc,
|
||||||
customDomainCname: customDomainCname,
|
customDomainCname: customDomainCname,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
@@ -47,6 +48,7 @@ type (
|
|||||||
authorize authz.AuthorizeFunc
|
authorize authz.AuthorizeFunc
|
||||||
probo *probo.Service
|
probo *probo.Service
|
||||||
iam *iam.Service
|
iam *iam.Service
|
||||||
|
esign *esign.Service
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
customDomainCname string
|
customDomainCname string
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,7 @@ func NewMux(
|
|||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
proboSvc *probo.Service,
|
proboSvc *probo.Service,
|
||||||
iamSvc *iam.Service,
|
iamSvc *iam.Service,
|
||||||
|
esignSvc *esign.Service,
|
||||||
cookieConfig securecookie.Config,
|
cookieConfig securecookie.Config,
|
||||||
tokenSecret string,
|
tokenSecret string,
|
||||||
connectorRegistry *connector.ConnectorRegistry,
|
connectorRegistry *connector.ConnectorRegistry,
|
||||||
@@ -66,7 +69,7 @@ func NewMux(
|
|||||||
|
|
||||||
safeRedirect := &saferedirect.SafeRedirect{AllowedHost: baseURL.Host()}
|
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.Group(func(r chi.Router) {
|
||||||
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
||||||
|
|||||||
@@ -2714,6 +2714,7 @@ type TrustCenterAccess implements Node {
|
|||||||
name: String!
|
name: String!
|
||||||
state: TrustCenterAccessState!
|
state: TrustCenterAccessState!
|
||||||
hasAcceptedNonDisclosureAgreement: Boolean!
|
hasAcceptedNonDisclosureAgreement: Boolean!
|
||||||
|
ndaSignature: ElectronicSignature @goField(forceResolver: true)
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
lastTokenExpiresAt: Datetime
|
lastTokenExpiresAt: Datetime
|
||||||
@@ -5387,3 +5388,149 @@ type CreateCustomDomainPayload {
|
|||||||
type DeleteCustomDomainPayload {
|
type DeleteCustomDomainPayload {
|
||||||
deletedCustomDomainId: ID!
|
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
45
pkg/server/api/console/v1/types/electronic_signature.go
Normal file
45
pkg/server/api/console/v1/types/electronic_signature.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1206,6 +1206,33 @@ type DocumentVersionSignatureOrder struct {
|
|||||||
Direction page.OrderDirection `json:"direction"`
|
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 {
|
type Evidence struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
@@ -1847,6 +1874,7 @@ type TrustCenterAccess struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
State coredata.TrustCenterAccessState `json:"state"`
|
State coredata.TrustCenterAccessState `json:"state"`
|
||||||
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`
|
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`
|
||||||
|
NdaSignature *ElectronicSignature `json:"ndaSignature,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
LastTokenExpiresAt *time.Time `json:"lastTokenExpiresAt,omitempty"`
|
LastTokenExpiresAt *time.Time `json:"lastTokenExpiresAt,omitempty"`
|
||||||
|
|||||||
@@ -1241,6 +1241,40 @@ func (r *documentVersionSignatureConnectionResolver) TotalCount(ctx context.Cont
|
|||||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
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.
|
// File is the resolver for the file field.
|
||||||
func (r *evidenceResolver) File(ctx context.Context, obj *types.Evidence) (*types.File, error) {
|
func (r *evidenceResolver) File(ctx context.Context, obj *types.Evidence) (*types.File, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionFileGet); err != nil {
|
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)
|
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.
|
// PendingRequestCount is the resolver for the pendingRequestCount field.
|
||||||
func (r *trustCenterAccessResolver) PendingRequestCount(ctx context.Context, obj *types.TrustCenterAccess) (int, error) {
|
func (r *trustCenterAccessResolver) PendingRequestCount(ctx context.Context, obj *types.TrustCenterAccess) (int, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionTrustCenterAccessGet); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionTrustCenterAccessGet); err != nil {
|
||||||
@@ -8784,6 +8854,11 @@ func (r *Resolver) DocumentVersionSignatureConnection() schema.DocumentVersionSi
|
|||||||
return &documentVersionSignatureConnectionResolver{r}
|
return &documentVersionSignatureConnectionResolver{r}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ElectronicSignature returns schema.ElectronicSignatureResolver implementation.
|
||||||
|
func (r *Resolver) ElectronicSignature() schema.ElectronicSignatureResolver {
|
||||||
|
return &electronicSignatureResolver{r}
|
||||||
|
}
|
||||||
|
|
||||||
// Evidence returns schema.EvidenceResolver implementation.
|
// Evidence returns schema.EvidenceResolver implementation.
|
||||||
func (r *Resolver) Evidence() schema.EvidenceResolver { return &evidenceResolver{r} }
|
func (r *Resolver) Evidence() schema.EvidenceResolver { return &evidenceResolver{r} }
|
||||||
|
|
||||||
@@ -9029,6 +9104,7 @@ type documentVersionResolver struct{ *Resolver }
|
|||||||
type documentVersionConnectionResolver struct{ *Resolver }
|
type documentVersionConnectionResolver struct{ *Resolver }
|
||||||
type documentVersionSignatureResolver struct{ *Resolver }
|
type documentVersionSignatureResolver struct{ *Resolver }
|
||||||
type documentVersionSignatureConnectionResolver struct{ *Resolver }
|
type documentVersionSignatureConnectionResolver struct{ *Resolver }
|
||||||
|
type electronicSignatureResolver struct{ *Resolver }
|
||||||
type evidenceResolver struct{ *Resolver }
|
type evidenceResolver struct{ *Resolver }
|
||||||
type evidenceConnectionResolver struct{ *Resolver }
|
type evidenceConnectionResolver struct{ *Resolver }
|
||||||
type fileResolver struct{ *Resolver }
|
type fileResolver struct{ *Resolver }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/securecookie"
|
"go.probo.inc/probo/pkg/securecookie"
|
||||||
"go.probo.inc/probo/pkg/server/api/authn"
|
"go.probo.inc/probo/pkg/server/api/authn"
|
||||||
@@ -28,11 +29,12 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/trust"
|
"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{
|
config := schema.Config{
|
||||||
Resolvers: &Resolver{
|
Resolvers: &Resolver{
|
||||||
iam: iamSvc,
|
iam: iamSvc,
|
||||||
trust: trustSvc,
|
trust: trustSvc,
|
||||||
|
esign: esignSvc,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
sessionCookie: authn.NewCookie(&cookieConfig),
|
sessionCookie: authn.NewCookie(&cookieConfig),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/securecookie"
|
"go.probo.inc/probo/pkg/securecookie"
|
||||||
@@ -46,6 +47,7 @@ type (
|
|||||||
|
|
||||||
Resolver struct {
|
Resolver struct {
|
||||||
trust *trust.Service
|
trust *trust.Service
|
||||||
|
esign *esign.Service
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
iam *iam.Service
|
iam *iam.Service
|
||||||
sessionCookie *authn.Cookie
|
sessionCookie *authn.Cookie
|
||||||
@@ -75,6 +77,7 @@ func NewMux(
|
|||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
iamSvc *iam.Service,
|
iamSvc *iam.Service,
|
||||||
trustSvc *trust.Service,
|
trustSvc *trust.Service,
|
||||||
|
esignSvc *esign.Service,
|
||||||
cookieConfig securecookie.Config,
|
cookieConfig securecookie.Config,
|
||||||
baseURL *baseurl.BaseURL,
|
baseURL *baseurl.BaseURL,
|
||||||
) *chi.Mux {
|
) *chi.Mux {
|
||||||
@@ -84,7 +87,7 @@ func NewMux(
|
|||||||
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
||||||
r.Use(compliancepage.NewMembershipMiddleware(trustSvc, logger))
|
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)
|
r.Handle("/graphql", graphqlHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ type TrustCenter implements Node {
|
|||||||
darkLogoFileUrl: String @goField(forceResolver: true)
|
darkLogoFileUrl: String @goField(forceResolver: true)
|
||||||
ndaFileName: String
|
ndaFileName: String
|
||||||
ndaFileUrl: String @goField(forceResolver: true)
|
ndaFileUrl: String @goField(forceResolver: true)
|
||||||
|
ndaSignature: ElectronicSignature @goField(forceResolver: true)
|
||||||
organization: Organization! @goField(forceResolver: true)
|
organization: Organization! @goField(forceResolver: true)
|
||||||
isViewerMember: Boolean! @goField(forceResolver: true)
|
isViewerMember: Boolean! @goField(forceResolver: true)
|
||||||
hasAcceptedNonDisclosureAgreement: Boolean! @goField(forceResolver: true)
|
hasAcceptedNonDisclosureAgreement: Boolean! @goField(forceResolver: true)
|
||||||
@@ -608,6 +609,143 @@ type AcceptNonDisclosureAgreementPayload {
|
|||||||
success: Boolean!
|
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 {
|
type Query {
|
||||||
viewer: Identity
|
viewer: Identity
|
||||||
node(id: ID!): Node!
|
node(id: ID!): Node!
|
||||||
@@ -647,4 +785,12 @@ type Mutation {
|
|||||||
requestTrustCenterFileAccess(
|
requestTrustCenterFileAccess(
|
||||||
input: RequestTrustCenterFileAccessInput!
|
input: RequestTrustCenterFileAccessInput!
|
||||||
): RequestAccessesPayload! @session(required: PRESENT)
|
): 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
32
pkg/server/api/trust/v1/types/electronic_signature.go
Normal file
32
pkg/server/api/trust/v1/types/electronic_signature.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,15 @@ type Node interface {
|
|||||||
GetID() gid.GID
|
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 {
|
type AcceptNonDisclosureAgreementInput struct {
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
}
|
}
|
||||||
@@ -64,6 +73,20 @@ type DocumentEdge struct {
|
|||||||
Node *Document `json:"node"`
|
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 {
|
type ExportDocumentPDFInput struct {
|
||||||
DocumentID gid.GID `json:"documentId"`
|
DocumentID gid.GID `json:"documentId"`
|
||||||
}
|
}
|
||||||
@@ -136,6 +159,15 @@ type PageInfo struct {
|
|||||||
type Query 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 {
|
type Report struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
@@ -178,6 +210,7 @@ type TrustCenter struct {
|
|||||||
DarkLogoFileURL *string `json:"darkLogoFileUrl,omitempty"`
|
DarkLogoFileURL *string `json:"darkLogoFileUrl,omitempty"`
|
||||||
NdaFileName *string `json:"ndaFileName,omitempty"`
|
NdaFileName *string `json:"ndaFileName,omitempty"`
|
||||||
NdaFileURL *string `json:"ndaFileUrl,omitempty"`
|
NdaFileURL *string `json:"ndaFileUrl,omitempty"`
|
||||||
|
NdaSignature *ElectronicSignature `json:"ndaSignature,omitempty"`
|
||||||
Organization *Organization `json:"organization"`
|
Organization *Organization `json:"organization"`
|
||||||
IsViewerMember bool `json:"isViewerMember"`
|
IsViewerMember bool `json:"isViewerMember"`
|
||||||
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`
|
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
@@ -648,6 +649,71 @@ func (r *mutationResolver) RequestTrustCenterFileAccess(ctx context.Context, inp
|
|||||||
}, nil
|
}, 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.
|
// LogoURL is the resolver for the logoUrl field.
|
||||||
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
|
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
|
||||||
trustService := r.TrustService(ctx, obj.ID.TenantID())
|
trustService := r.TrustService(ctx, obj.ID.TenantID())
|
||||||
@@ -868,6 +934,32 @@ func (r *trustCenterResolver) NdaFileURL(ctx context.Context, obj *types.TrustCe
|
|||||||
return &fileURL, nil
|
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.
|
// Organization is the resolver for the organization field.
|
||||||
func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.TrustCenter) (*types.Organization, error) {
|
func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.TrustCenter) (*types.Organization, error) {
|
||||||
return obj.Organization, nil
|
return obj.Organization, nil
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/agents"
|
"go.probo.inc/probo/pkg/agents"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/securecookie"
|
"go.probo.inc/probo/pkg/securecookie"
|
||||||
@@ -43,6 +44,7 @@ type Config struct {
|
|||||||
Probo *probo.Service
|
Probo *probo.Service
|
||||||
IAM *iam.Service
|
IAM *iam.Service
|
||||||
Trust *trust.Service
|
Trust *trust.Service
|
||||||
|
ESign *esign.Service
|
||||||
Slack *slack.Service
|
Slack *slack.Service
|
||||||
Cookie securecookie.Config
|
Cookie securecookie.Config
|
||||||
TokenSecret string
|
TokenSecret string
|
||||||
@@ -70,6 +72,7 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
Probo: cfg.Probo,
|
Probo: cfg.Probo,
|
||||||
IAM: cfg.IAM,
|
IAM: cfg.IAM,
|
||||||
Trust: cfg.Trust,
|
Trust: cfg.Trust,
|
||||||
|
ESign: cfg.ESign,
|
||||||
Slack: cfg.Slack,
|
Slack: cfg.Slack,
|
||||||
Cookie: cfg.Cookie,
|
Cookie: cfg.Cookie,
|
||||||
TokenSecret: cfg.TokenSecret,
|
TokenSecret: cfg.TokenSecret,
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
|
"go.probo.inc/probo/packages/emails"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/html2pdf"
|
"go.probo.inc/probo/pkg/html2pdf"
|
||||||
@@ -43,6 +45,7 @@ type (
|
|||||||
slackSigningSecret string
|
slackSigningSecret string
|
||||||
baseURL string
|
baseURL string
|
||||||
iam *iam.Service
|
iam *iam.Service
|
||||||
|
esign *esign.Service
|
||||||
html2pdfConverter *html2pdf.Converter
|
html2pdfConverter *html2pdf.Converter
|
||||||
fileManager *filemanager.Service
|
fileManager *filemanager.Service
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
@@ -58,6 +61,7 @@ type (
|
|||||||
encryptionKey cipher.EncryptionKey
|
encryptionKey cipher.EncryptionKey
|
||||||
baseURL string
|
baseURL string
|
||||||
iam *iam.Service
|
iam *iam.Service
|
||||||
|
esign *esign.Service
|
||||||
html2pdfConverter *html2pdf.Converter
|
html2pdfConverter *html2pdf.Converter
|
||||||
fileManager *filemanager.Service
|
fileManager *filemanager.Service
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
@@ -83,6 +87,7 @@ func NewService(
|
|||||||
encryptionKey cipher.EncryptionKey,
|
encryptionKey cipher.EncryptionKey,
|
||||||
slackSigningSecret string,
|
slackSigningSecret string,
|
||||||
iam *iam.Service,
|
iam *iam.Service,
|
||||||
|
esignSvc *esign.Service,
|
||||||
html2pdfConverter *html2pdf.Converter,
|
html2pdfConverter *html2pdf.Converter,
|
||||||
fileManagerService *filemanager.Service,
|
fileManagerService *filemanager.Service,
|
||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
@@ -96,6 +101,7 @@ func NewService(
|
|||||||
slackSigningSecret: slackSigningSecret,
|
slackSigningSecret: slackSigningSecret,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
iam: iam,
|
iam: iam,
|
||||||
|
esign: esignSvc,
|
||||||
html2pdfConverter: html2pdfConverter,
|
html2pdfConverter: html2pdfConverter,
|
||||||
fileManager: fileManagerService,
|
fileManager: fileManagerService,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -113,6 +119,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
|||||||
encryptionKey: s.encryptionKey,
|
encryptionKey: s.encryptionKey,
|
||||||
baseURL: s.baseURL,
|
baseURL: s.baseURL,
|
||||||
iam: s.iam,
|
iam: s.iam,
|
||||||
|
esign: s.esign,
|
||||||
html2pdfConverter: s.html2pdfConverter,
|
html2pdfConverter: s.html2pdfConverter,
|
||||||
fileManager: s.fileManager,
|
fileManager: s.fileManager,
|
||||||
logger: s.logger,
|
logger: s.logger,
|
||||||
@@ -253,6 +260,21 @@ func (s *Service) GetCustomDomainByOrganizationID(ctx context.Context, organizat
|
|||||||
return customDomain, err
|
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) {
|
func (s *Service) GetMembershipByCompliancePageIDAndEmail(ctx context.Context, compliancePageID gid.GID, email mail.Addr) (*coredata.TrustCenterAccess, error) {
|
||||||
membership := &coredata.TrustCenterAccess{}
|
membership := &coredata.TrustCenterAccess{}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/packages/emails"
|
"go.probo.inc/probo/packages/emails"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/mail"
|
"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 {
|
if err := access.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||||
return fmt.Errorf("cannot insert trust center access: %w", err)
|
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
|
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) {
|
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 := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||||
err := access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, trustCenterID, email)
|
return access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, trustCenterID, email)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot load trust center access: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 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 {
|
type AcceptNDARequest struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user