chore: merged feat/refresh

This commit is contained in:
pit
2023-09-26 16:27:11 +01:00
229 changed files with 8108 additions and 1272 deletions

View File

@ -9,7 +9,9 @@
"server-only/",
"universal/"
],
"scripts": {},
"scripts": {
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*"

View File

@ -13,15 +13,17 @@
],
"scripts": {
"dev": "email dev --port 3002 --dir templates",
"clean": "rimraf node_modules",
"worker:test": "tsup worker/index.ts --format esm"
},
"dependencies": {
"@react-email/components": "^0.0.7",
"nodemailer": "^6.9.3"
"nodemailer": "^6.9.3",
"react-email": "^1.9.4"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.8",
"tsup": "^7.1.0"
}

View File

@ -1,17 +1,15 @@
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
import { Button, Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
export interface TemplateDocumentCompletedProps {
downloadLink: string;
reviewLink: string;
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentCompleted = ({
downloadLink,
reviewLink,
documentName,
assetBaseUrl,
}: TemplateDocumentCompletedProps) => {
@ -29,11 +27,23 @@ export const TemplateDocumentCompleted = ({
},
}}
>
<Section className="flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Section>
<Row className="table-fixed">
<Column />
<Column>
<Img
className="h-42 mx-auto"
src={getAssetUrl('/static/document.png')}
alt="Documenso"
/>
</Column>
<Column />
</Row>
</Section>
<Section>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
Completed
@ -44,17 +54,17 @@ export const TemplateDocumentCompleted = ({
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by downloading or reviewing the document.
Continue by downloading the document.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
{/* <Button
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={reviewLink}
>
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
Review
</Button>
</Button> */}
<Button
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={downloadLink}

View File

@ -1,4 +1,4 @@
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
import { Button, Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
@ -30,13 +30,26 @@ export const TemplateDocumentInvite = ({
},
}}
>
<Section className="mt-4 flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Section className="mt-4">
<Row className="table-fixed">
<Column />
<Column>
<Img
className="h-42 mx-auto"
src={getAssetUrl('/static/document.png')}
alt="Documenso"
/>
</Column>
<Column />
</Row>
</Section>
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has invited you to sign "{documentName}"
{inviterName} has invited you to sign
<br />"{documentName}"
</Text>
<Text className="my-1 text-center text-base text-slate-400">

View File

@ -1,4 +1,4 @@
import { Img, Section, Tailwind, Text } from '@react-email/components';
import { Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
@ -25,11 +25,23 @@ export const TemplateDocumentPending = ({
},
}}
>
<Section className="flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Section>
<Row className="table-fixed">
<Column />
<Column>
<Img
className="h-42 mx-auto"
src={getAssetUrl('/static/document.png')}
alt="Documenso"
/>
</Column>
<Column />
</Row>
</Section>
<Section>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
Waiting for others

View File

@ -0,0 +1,69 @@
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
export interface TemplateDocumentSelfSignedProps {
downloadLink: string;
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentSelfSigned = ({
downloadLink,
documentName,
assetBaseUrl,
}: TemplateDocumentSelfSignedProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Section className="flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
Completed
</Text>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
You have signed {documentName}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Check out our plans to access the full suite of features.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href="https://documenso.com/pricing"
>
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
View plans
</Button>
<Button
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="-mb-1 mr-2 inline h-5 w-5" />
Download
</Button>
</Section>
</Section>
</Tailwind>
);
};
export default TemplateDocumentSelfSigned;

View File

@ -1,17 +1,23 @@
import { Link, Section, Text } from '@react-email/components';
export const TemplateFooter = () => {
export type TemplateFooterProps = {
isDocument?: boolean;
};
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
return (
<Section>
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
{isDocument && (
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
)}
<Text className="my-8 text-sm text-slate-400">
Documenso
Documenso, Inc.
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>

View File

@ -0,0 +1,54 @@
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
export type TemplateForgotPasswordProps = {
resetPasswordLink: string;
assetBaseUrl: string;
};
export const TemplateForgotPassword = ({
resetPasswordLink,
assetBaseUrl,
}: TemplateForgotPasswordProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Section className="mt-4 flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
Forgot your password?
</Text>
<Text className="my-1 text-center text-base text-slate-400">
That's okay, it happens! Click the button below to reset your password.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={resetPasswordLink}
>
Reset Password
</Button>
</Section>
</Section>
</Tailwind>
);
};
export default TemplateForgotPassword;

View File

@ -0,0 +1,43 @@
import { Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
export interface TemplateResetPasswordProps {
userName: string;
userEmail: string;
assetBaseUrl: string;
}
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Section className="mt-4 flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
Password updated!
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Your password has been updated.
</Text>
</Section>
</Tailwind>
);
};
export default TemplateResetPassword;

View File

@ -21,7 +21,6 @@ export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentComple
export const DocumentCompletedEmailTemplate = ({
downloadLink = 'https://documenso.com',
reviewLink = 'https://documenso.com',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentCompletedEmailTemplateProps) => {
@ -56,7 +55,6 @@ export const DocumentCompletedEmailTemplate = ({
<TemplateDocumentCompleted
downloadLink={downloadLink}
reviewLink={reviewLink}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
/>

View File

@ -20,7 +20,9 @@ import {
} from '../template-components/template-document-invite';
import TemplateFooter from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps>;
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
customBody?: string;
};
export const DocumentInviteEmailTemplate = ({
inviterName = 'Lucas Smith',
@ -28,6 +30,7 @@ export const DocumentInviteEmailTemplate = ({
documentName = 'Open Source Pledge.pdf',
signDocumentLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002',
customBody,
}: DocumentInviteEmailTemplateProps) => {
const previewText = `Completed Document`;
@ -78,7 +81,11 @@ export const DocumentInviteEmailTemplate = ({
</Text>
<Text className="mt-2 text-base text-slate-400">
{inviterName} has invited you to sign the document "{documentName}".
{customBody ? (
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
) : (
`${inviterName} has invited you to sign the document "${documentName}".`
)}
</Text>
</Section>
</Container>

View File

@ -0,0 +1,74 @@
import {
Body,
Container,
Head,
Html,
Img,
Preview,
Section,
Tailwind,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import {
TemplateDocumentSelfSigned,
TemplateDocumentSelfSignedProps,
} from '../template-components/template-document-self-signed';
import TemplateFooter from '../template-components/template-footer';
export type DocumentSelfSignedTemplateProps = TemplateDocumentSelfSignedProps;
export const DocumentSelfSignedEmailTemplate = ({
downloadLink = 'https://documenso.com',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentSelfSignedTemplateProps) => {
const previewText = `Completed Document`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateDocumentSelfSigned
downloadLink={downloadLink}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentSelfSignedEmailTemplate;

View File

@ -0,0 +1,74 @@
import {
Body,
Container,
Head,
Html,
Img,
Preview,
Section,
Tailwind,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import TemplateFooter from '../template-components/template-footer';
import {
TemplateForgotPassword,
TemplateForgotPasswordProps,
} from '../template-components/template-forgot-password';
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;
export const ForgotPasswordTemplate = ({
resetPasswordLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002',
}: ForgotPasswordTemplateProps) => {
const previewText = `Password Reset Requested`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateForgotPassword
resetPasswordLink={resetPasswordLink}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default ForgotPasswordTemplate;

View File

@ -0,0 +1,102 @@
import {
Body,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import TemplateFooter from '../template-components/template-footer';
import {
TemplateResetPassword,
TemplateResetPasswordProps,
} from '../template-components/template-reset-password';
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
export const ResetPasswordTemplate = ({
userName = 'Lucas Smith',
userEmail = 'lucas@documenso.com',
assetBaseUrl = 'http://localhost:3002',
}: ResetPasswordTemplateProps) => {
const previewText = `Password Reset Successful`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateResetPassword
userName={userName}
userEmail={userEmail}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Container className="mx-auto mt-12 max-w-xl">
<Section>
<Text className="my-4 text-base font-semibold">
Hi, {userName}{' '}
<Link className="font-normal text-slate-400" href={`mailto:${userEmail}`}>
({userEmail})
</Link>
</Text>
<Text className="mt-2 text-base text-slate-400">
We've changed your password as you asked. You can now sign in with your new
password.
</Text>
<Text className="mt-2 text-base text-slate-400">
Didn't request a password change? We are here to help you secure your account,
just{' '}
<Link className="text-documenso-700 font-normal" href="mailto:hi@documenso.com">
contact us.
</Link>
</Text>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default ResetPasswordTemplate;

View File

@ -3,11 +3,14 @@
"version": "0.0.0",
"main": "./index.cjs",
"license": "MIT",
"scripts": {
"clean": "rimraf node_modules"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.40.0",
"eslint-config-next": "13.4.12",
"eslint-config-next": "13.4.19",
"eslint-config-prettier": "^8.8.0",
"eslint-config-turbo": "^1.9.3",
"eslint-plugin-package-json": "^0.1.4",
@ -15,4 +18,4 @@
"eslint-plugin-react": "^7.32.2",
"typescript": "^5.1.6"
}
}
}

View File

@ -0,0 +1,61 @@
import { posthog } from 'posthog-js';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import {
FEATURE_FLAG_GLOBAL_SESSION_RECORDING,
extractPostHogConfig,
} from '@documenso/lib/constants/feature-flags';
export function useAnalytics() {
const featureFlags = useFeatureFlags();
const isPostHogEnabled = extractPostHogConfig();
/**
* Capture an analytic event.
*
* @param event The event name.
* @param properties Properties to attach to the event.
*/
const capture = (event: string, properties?: Record<string, unknown>) => {
if (!isPostHogEnabled) {
return;
}
posthog.capture(event, properties);
};
/**
* Start the session recording.
*
* @param eventFlag The event to check against feature flags to determine whether tracking is enabled.
*/
const startSessionRecording = (eventFlag?: string) => {
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
return;
}
posthog.startSessionRecording();
};
/**
* Stop the current session recording.
*/
const stopSessionRecording = () => {
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
if (!isPostHogEnabled || !isSessionRecordingEnabled) {
return;
}
posthog.stopSessionRecording();
};
return {
capture,
startSessionRecording,
stopSessionRecording,
};
}

View File

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay: number) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { RefObject, useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.
*
* @param text The text to calculate the width and height of.
* @param fontSize The font size to apply to the text.
* @param fontFamily The font family to apply to the text.
* @returns Returns the width and height of the text.
*/
function calculateTextDimensions(
text: string,
fontSize: string,
fontFamily: string,
): { width: number; height: number } {
// Reuse old canvas if available.
let canvas = (calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas;
if (!canvas) {
canvas = document.createElement('canvas');
(calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas = canvas;
}
const context = canvas.getContext('2d');
if (!context) {
return { width: 0, height: 0 };
}
context.font = `${fontSize} ${fontFamily}`;
const metrics = context.measureText(text);
return {
width: metrics.width,
height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
};
}
/**
* Calculate the scaling size to apply to a text to fit it within a container.
*
* @param container The container dimensions to fit the text within.
* @param text The text to fit within the container.
* @param fontSize The font size to apply to the text.
* @param fontFamily The font family to apply to the text.
* @returns Returns a value between 0 and 1 which represents the scaling factor to apply to the text.
*/
export const calculateTextScaleSize = (
container: { width: number; height: number },
text: string,
fontSize: string,
fontFamily: string,
) => {
const { width, height } = calculateTextDimensions(text, fontSize, fontFamily);
return Math.min(container.width / width, container.height / height, 1);
};
/**
* Given a container and child element, calculate the scaling size to apply to the child.
*/
export function useElementScaleSize(
container: { width: number; height: number },
child: RefObject<HTMLElement | null>,
fontSize: number,
fontFamily: string,
) {
const [scalingFactor, setScalingFactor] = useState(1);
useEffect(() => {
if (!child.current) {
return;
}
const scaleSize = calculateTextScaleSize(
container,
child.current.innerText,
`${fontSize}px`,
fontFamily,
);
setScalingFactor(scaleSize);
}, [child, container, fontFamily, fontSize]);
return scalingFactor;
}

View File

@ -0,0 +1,78 @@
import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
// X and Y are percentages of the page's height and width
const fieldX = (Number(field.positionX) / 100) * width + left;
const fieldY = (Number(field.positionY) / 100) * height + top;
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
return coords;
};

View File

@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
export const useIsMounted = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted;
};

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
export function useWindowSize() {
const [size, setSize] = useState({
width: 0,
height: 0,
});
const onResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
useEffect(() => {
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
return size;
}

View File

@ -0,0 +1,95 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import {
FEATURE_FLAG_POLL_INTERVAL,
LOCAL_FEATURE_FLAGS,
isFeatureFlagEnabled,
} from '@documenso/lib/constants/feature-flags';
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
import { TFeatureFlagValue } from './feature-flag.types';
export type FeatureFlagContextValue = {
getFlag: (_key: string) => TFeatureFlagValue;
};
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
export const useFeatureFlags = () => {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
}
return context;
};
export function FeatureFlagProvider({
children,
initialFlags,
}: {
children: React.ReactNode;
initialFlags: Record<string, TFeatureFlagValue>;
}) {
const [flags, setFlags] = useState(initialFlags);
const getFlag = useCallback(
(flag: string) => {
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
return flags[flag] ?? false;
},
[flags],
);
/**
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const interval = setInterval(() => {
if (document.hasFocus()) {
void getAllFlags().then((newFlags) => setFlags(newFlags));
}
}, FEATURE_FLAG_POLL_INTERVAL);
return () => {
clearInterval(interval);
};
}, []);
/**
* Refresh the flags when the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return (
<FeatureFlagContext.Provider
value={{
getFlag,
}}
>
{children}
</FeatureFlagContext.Provider>
);
}

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZFeatureFlagValueSchema = z.union([
z.boolean(),
z.string(),
z.number(),
z.undefined(),
]);
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;

View File

@ -0,0 +1,8 @@
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
export const APP_BASE_URL = IS_APP_WEB
? process.env.NEXT_PUBLIC_WEBAPP_URL
: process.env.NEXT_PUBLIC_MARKETING_URL;

View File

@ -0,0 +1,4 @@
export const FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
export const FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';

View File

@ -1,3 +1,8 @@
/**
* The flag name for global session recording feature flag.
*/
export const FEATURE_FLAG_GLOBAL_SESSION_RECORDING = 'global_session_recording';
/**
* How frequent to poll for new feature flags in milliseconds.
*/
@ -10,6 +15,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
marketing_header_single_player_mode: false,
} as const;
/**

View File

@ -0,0 +1,9 @@
import { APP_BASE_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 15;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20;
export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`;

View File

@ -10,13 +10,16 @@
"universal/",
"next-auth/"
],
"scripts": {},
"scripts": {
"clean": "rimraf node_modules"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@next-auth/prisma-adapter": "1.0.7",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
@ -25,7 +28,7 @@
"bcrypt": "^5.1.0",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",
"next": "13.4.12",
"next": "13.4.19",
"next-auth": "4.22.3",
"pdf-lib": "^1.17.1",
"react": "18.2.0",

View File

@ -0,0 +1,53 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
export interface SendForgotPasswordOptions {
userId: number;
}
export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
PasswordResetToken: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
if (!user) {
throw new Error('User not found');
}
const token = user.PasswordResetToken[0].token;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const resetPasswordLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,
resetPasswordLink,
});
return await mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Forgot Password?',
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@ -0,0 +1,40 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
export interface SendResetPasswordOptions {
userId: number;
}
export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(ResetPasswordTemplate, {
assetBaseUrl,
userEmail: user.email,
userName: user.name || '',
});
return await mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Password Reset Success!',
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@ -0,0 +1,30 @@
'use server';
import { prisma } from '@documenso/prisma';
export type CreateDocumentMetaOptions = {
documentId: number;
subject: string;
message: string;
};
export const upsertDocumentMeta = async ({
subject,
message,
documentId,
}: CreateDocumentMetaOptions) => {
return await prisma.documentMeta.upsert({
where: {
documentId,
},
create: {
subject,
message,
documentId,
},
update: {
subject,
message,
},
});
};

View File

@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
@ -69,6 +70,19 @@ export const completeDocumentWithToken = async ({
},
});
const pendingRecipients = await prisma.recipient.count({
where: {
documentId: document.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
},
});
if (pendingRecipients > 0) {
await sendPendingEmail({ documentId, recipientId: recipient.id });
}
const documents = await prisma.document.updateMany({
where: {
id: document.id,

View File

@ -13,6 +13,7 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
},
include: {
documentData: true,
documentMeta: true,
},
});
};

View File

@ -1,12 +1,21 @@
import { prisma } from '@documenso/prisma';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
export interface GetDocumentAndSenderByTokenOptions {
token: string;
}
export interface GetDocumentAndRecipientByTokenOptions {
token: string;
}
export const getDocumentAndSenderByToken = async ({
token,
}: GetDocumentAndSenderByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
}
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
@ -29,3 +38,33 @@ export const getDocumentAndSenderByToken = async ({
User,
};
};
/**
* Get a Document and a Recipient by the recipient token.
*/
export const getDocumentAndRecipientByToken = async ({
token,
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
if (!token) {
throw new Error('Missing token');
}
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
include: {
Recipient: true,
documentData: true,
},
});
return {
...result,
Recipient: result.Recipient[0],
};
};

View File

@ -5,10 +5,12 @@ import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
documentId: number;
@ -70,12 +72,14 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
const pdfBytes = await doc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(pdfBytes)),
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
await prisma.documentData.update({
@ -86,4 +90,6 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
data: newData,
},
});
await sendCompletedEmail({ documentId });
};

View File

@ -0,0 +1,57 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
export interface SendDocumentOptions {
documentId: number;
}
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
const document = await prisma.document.findUnique({
where: {
id: documentId,
},
include: {
Recipient: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
throw new Error('Document has no recipients');
}
await Promise.all([
document.Recipient.map(async (recipient) => {
const { email, name, token } = recipient;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,
assetBaseUrl,
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
});
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
});
}),
]);
};

View File

@ -3,13 +3,15 @@ import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
export interface SendDocumentOptions {
export type SendDocumentOptions = {
documentId: number;
userId: number;
}
};
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
@ -25,9 +27,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
},
include: {
Recipient: true,
documentMeta: true,
},
});
const customEmail = document?.documentMeta;
if (!document) {
throw new Error('Document not found');
}
@ -44,6 +49,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
document.Recipient.map(async (recipient) => {
const { email, name } = recipient;
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
if (recipient.sendStatus === SendStatus.SENT) {
return;
}
@ -57,6 +68,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
});
await mailer.sendMail({
@ -65,10 +77,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
name,
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Please sign this document',
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document',
html: render(template),
text: render(template, { plainText: true }),
});

View File

@ -0,0 +1,64 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
export interface SendPendingEmailOptions {
documentId: number;
recipientId: number;
}
export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingEmailOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
Recipient: {
some: {
id: recipientId,
},
},
},
include: {
Recipient: {
where: {
id: recipientId,
},
},
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
throw new Error('Document has no recipients');
}
const [recipient] = document.Recipient;
const { email, name } = recipient;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(DocumentPendingEmailTemplate, {
documentName: document.title,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Waiting for others to complete signing.',
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@ -0,0 +1,21 @@
'use server';
import { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type UpdateDocumentOptions = {
documentId: number;
data: Prisma.DocumentUpdateInput;
};
export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => {
return await prisma.document.update({
where: {
id: documentId,
},
data: {
...data,
},
});
};

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
/**
* Get all the evaluated feature flags based on the current user if possible.
*/
export default async function handlerFeatureFlagAll(req: Request) {
const requestHeaders = Object.fromEntries(req.headers.entries());
const nextReq = new NextRequest(req, {
headers: requestHeaders,
});
const token = await getToken({ req: nextReq });
const postHog = PostHogServerClient();
// Return the local feature flags if PostHog is not enabled, true by default.
// The front end should not call this API if PostHog is not enabled to reduce network requests.
if (!postHog) {
return NextResponse.json(LOCAL_FEATURE_FLAGS);
}
const distinctId = extractDistinctUserId(token, nextReq);
const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
const res = NextResponse.json(featureFlags);
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
const origin = req.headers.get('origin');
if (origin) {
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}
return res;
}

View File

@ -0,0 +1,16 @@
import { PostHog } from 'posthog-node';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export default function PostHogServerClient() {
const postHogConfig = extractPostHogConfig();
if (!postHogConfig) {
return null;
}
return new PostHog(postHogConfig.key, {
host: postHogConfig.host,
fetch: async (...args) => fetch(...args),
});
}

View File

@ -0,0 +1,26 @@
import { headers } from 'next/headers';
import { getAllFlags, getFlag } from '@documenso/lib/universal/get-feature-flag';
/**
* Evaluate whether a flag is enabled for the current user in a server component.
*
* @param flag The flag to evaluate.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getServerComponentFlag = async (flag: string) => {
return await getFlag(flag, {
requestHeaders: Object.fromEntries(headers().entries()),
});
};
/**
* Get all feature flags for the current user from a server component.
*
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getServerComponentAllFlags = async () => {
return await getAllFlags({
requestHeaders: Object.fromEntries(headers().entries()),
});
};

View File

@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
import { JWT, getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
/**
* Evaluate a single feature flag based on the current user if possible.
*
* @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
* @returns A Response with the feature flag value.
*/
export default async function handleFeatureFlagGet(req: Request) {
const { searchParams } = new URL(req.url ?? '');
const flag = searchParams.get('flag');
const requestHeaders = Object.fromEntries(req.headers.entries());
const nextReq = new NextRequest(req, {
headers: requestHeaders,
});
const token = await getToken({ req: nextReq });
if (!flag) {
return NextResponse.json(
{
error: 'Missing flag query parameter.',
},
{
status: 400,
headers: {
'content-type': 'application/json',
},
},
);
}
const postHog = PostHogServerClient();
// Return the local feature flags if PostHog is not enabled, true by default.
// The front end should not call this API if PostHog is disabled to reduce network requests.
if (!postHog) {
return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
}
const distinctId = extractDistinctUserId(token, nextReq);
const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
const res = NextResponse.json(featureFlag);
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
const origin = req.headers.get('Origin');
if (origin) {
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}
return res;
}
/**
* Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
*
* @param jwt The JWT of the current user.
* @returns A map of properties which are consumed by PostHog.
*/
export const mapJwtToFlagProperties = (
jwt?: JWT | null,
): {
groups?: Record<string, string>;
personProperties?: Record<string, string>;
groupProperties?: Record<string, Record<string, string>>;
} => {
return {
personProperties: {
email: jwt?.email ?? '',
},
groupProperties: {
// Add properties to group users into different groups, such as billing plan.
},
};
};
/**
* Extract a distinct ID from a JWT and request.
*
* Will fallback to a random ID if no ID could be extracted from either the JWT or request.
*
* @param jwt The JWT of the current user.
* @param request Request potentially containing a PostHog `distinct_id` cookie.
* @returns A distinct user ID.
*/
export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
const config = extractPostHogConfig();
const email = jwt?.email;
const userId = jwt?.id.toString();
let fallbackDistinctId = nanoid();
if (config) {
try {
const postHogCookie = JSON.parse(
request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
);
const postHogDistinctId = postHogCookie['distinct_id'];
if (typeof postHogDistinctId === 'string') {
fallbackDistinctId = postHogDistinctId;
}
} catch {
// Do nothing.
}
}
return email ?? userId ?? fallbackDistinctId;
};

View File

@ -13,6 +13,9 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
userId,
},
},
orderBy: {
id: 'asc',
},
});
return fields;

View File

@ -1,11 +1,12 @@
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions {
userId: number;
documentId: number;
fields: {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
@ -54,62 +55,56 @@ export const setFieldsForDocument = async ({
return {
...field,
...existing,
_persisted: existing,
};
})
.filter((field) => {
return (
field.Recipient?.sendStatus !== SendStatus.SENT &&
field.Recipient?.signingStatus !== SigningStatus.SIGNED
field._persisted?.Recipient?.sendStatus !== SendStatus.SENT &&
field._persisted?.Recipient?.signingStatus !== SigningStatus.SIGNED
);
});
const persistedFields = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map((field) =>
field.id
? prisma.field.update({
where: {
id: field.id,
recipientId: field.recipientId,
documentId,
prisma.field.upsert({
where: {
id: field._persisted?.id ?? -1,
documentId,
},
update: {
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
data: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
})
: prisma.field.create({
data: {
// TODO: Rewrite this entire transaction because this is a mess
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
type: field.type!,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: document.id,
},
},
Recipient: {
connect: {
documentId_email: {
documentId: document.id,
email: field.signerEmail,
},
},
},
Recipient: {
connect: {
documentId_email: {
documentId,
email: field.signerEmail.toLowerCase(),
},
},
}),
},
},
}),
),
);

View File

@ -1,25 +1,31 @@
import fontkit from '@pdf-lib/fontkit';
import { readFileSync } from 'fs';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import {
CAVEAT_FONT_PATH,
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
const DEFAULT_STANDARD_FONT_SIZE = 15;
const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH);
const fontCaveat = await fontResponse.arrayBuffer();
const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit);
const fontCaveat = readFileSync('./public/fonts/caveat.ttf');
const pages = pdf.getPages();
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
let fontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
let fontSize = maxFontSize;
const page = pages.at(field.page - 1);
@ -50,11 +56,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
let imageWidth = image.width;
let imageHeight = image.height;
const initialDimensions = {
width: imageWidth,
height: imageHeight,
};
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
@ -76,14 +77,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize);
const initialDimensions = {
width: textWidth,
height: textHeight,
};
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(fontSize * scalingFactor, maxFontSize);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2;

View File

@ -1,7 +1,8 @@
import fontkit from '@pdf-lib/fontkit';
import * as fs from 'fs';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { CAVEAT_FONT_PATH } from '../../constants/pdf';
export async function insertTextInPDF(
pdfAsBase64: string,
text: string,
@ -10,13 +11,15 @@ export async function insertTextInPDF(
page = 0,
useHandwritingFont = true,
): Promise<string> {
const fontBytes = fs.readFileSync('./public/fonts/caveat.ttf');
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH);
const fontCaveat = await fontResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(pdfAsBase64);
pdfDoc.registerFontkit(fontkit);
const font = await pdfDoc.embedFont(useHandwritingFont ? fontBytes : StandardFonts.Helvetica);
const font = await pdfDoc.embedFont(useHandwritingFont ? fontCaveat : StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];

View File

@ -16,6 +16,9 @@ export const getRecipientsForDocument = async ({
userId,
},
},
orderBy: {
id: 'asc',
},
});
return recipients;

View File

@ -29,6 +29,11 @@ export const setRecipientsForDocument = async ({
throw new Error('Document not found');
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const existingRecipients = await prisma.recipient.findMany({
where: {
documentId,
@ -37,13 +42,13 @@ export const setRecipientsForDocument = async ({
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!recipients.find(
!normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
);
const linkedRecipients = recipients
const linkedRecipients = normalizedRecipients
.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
@ -62,27 +67,26 @@ export const setRecipientsForDocument = async ({
});
const persistedRecipients = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedRecipients.map((recipient) =>
recipient.id
? prisma.recipient.update({
where: {
id: recipient.id,
documentId,
},
data: {
name: recipient.name,
email: recipient.email,
documentId,
},
})
: prisma.recipient.create({
data: {
name: recipient.name,
email: recipient.email,
token: nanoid(),
documentId,
},
}),
prisma.recipient.upsert({
where: {
id: recipient.id ?? -1,
documentId,
},
update: {
name: recipient.name,
email: recipient.email,
documentId,
},
create: {
name: recipient.name,
email: recipient.email,
token: nanoid(),
documentId,
},
}),
),
);

View File

@ -0,0 +1,58 @@
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { alphaid } from '../../universal/id';
export type CreateSharingIdOptions =
| {
documentId: number;
token: string;
}
| {
documentId: number;
userId: number;
};
export const createOrGetShareLink = async ({ documentId, ...options }: CreateSharingIdOptions) => {
const email = await match(options)
.with({ token: P.string }, async ({ token }) => {
return await prisma.recipient
.findFirst({
where: {
documentId,
token,
},
})
.then((recipient) => recipient?.email);
})
.with({ userId: P.number }, async ({ userId }) => {
return await prisma.user
.findFirst({
where: {
id: userId,
},
})
.then((user) => user?.email);
})
.exhaustive();
if (!email) {
throw new Error('Unable to create share link for document with the given email');
}
return await prisma.documentShareLink.upsert({
where: {
documentId_email: {
email,
documentId,
},
},
create: {
email,
documentId,
slug: alphaid(14),
},
update: {},
});
};

View File

@ -0,0 +1,42 @@
import { prisma } from '@documenso/prisma';
export type GetRecipientOrSenderByShareLinkSlugOptions = {
slug: string;
};
export const getRecipientOrSenderByShareLinkSlug = async ({
slug,
}: GetRecipientOrSenderByShareLinkSlugOptions) => {
const { documentId, email } = await prisma.documentShareLink.findFirstOrThrow({
where: {
slug,
},
});
const recipient = await prisma.recipient.findFirst({
where: {
documentId,
email,
},
include: {
Signature: true,
},
});
if (recipient) {
return recipient;
}
const sender = await prisma.user.findFirst({
where: {
Document: { some: { id: documentId } },
email,
},
});
if (sender) {
return sender;
}
throw new Error('Recipient or sender not found');
};

View File

@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export type GetShareLinkBySlugOptions = {
slug: string;
};
export const getShareLinkBySlug = async ({ slug }: GetShareLinkBySlugOptions) => {
return await prisma.documentShareLink.findFirstOrThrow({
where: {
slug,
},
});
};

View File

@ -6,7 +6,7 @@ export type GetSubscriptionByUserIdOptions = {
userId: number;
};
export const getSubscriptionByUserId = ({ userId }: GetSubscriptionByUserIdOptions) => {
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
return prisma.subscription.findFirst({
where: {
userId,

View File

@ -0,0 +1,53 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { TForgotPasswordFormSchema } from '@documenso/trpc/server/profile-router/schema';
import { ONE_DAY, ONE_HOUR } from '../../constants/time';
import { sendForgotPassword } from '../auth/send-forgot-password';
export const forgotPassword = async ({ email }: TForgotPasswordFormSchema) => {
const user = await prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
if (!user) {
return;
}
// Find a token that was created in the last hour and hasn't expired
const existingToken = await prisma.passwordResetToken.findFirst({
where: {
userId: user.id,
expiry: {
gt: new Date(),
},
createdAt: {
gt: new Date(Date.now() - ONE_HOUR),
},
},
});
if (existingToken) {
return;
}
const token = crypto.randomBytes(18).toString('hex');
await prisma.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_DAY),
userId: user.id,
},
});
await sendForgotPassword({
userId: user.id,
}).catch((err) => console.error(err));
};

View File

@ -0,0 +1,19 @@
import { prisma } from '@documenso/prisma';
type GetResetTokenValidityOptions = {
token: string;
};
export const getResetTokenValidity = async ({ token }: GetResetTokenValidityOptions) => {
const found = await prisma.passwordResetToken.findFirst({
select: {
id: true,
expiry: true,
},
where: {
token,
},
});
return !!found && found.expiry > new Date();
};

View File

@ -0,0 +1,62 @@
import { compare, hash } from 'bcrypt';
import { prisma } from '@documenso/prisma';
import { SALT_ROUNDS } from '../../constants/auth';
import { sendResetPassword } from '../auth/send-reset-password';
export type ResetPasswordOptions = {
token: string;
password: string;
};
export const resetPassword = async ({ token, password }: ResetPasswordOptions) => {
if (!token) {
throw new Error('Invalid token provided. Please try again.');
}
const foundToken = await prisma.passwordResetToken.findFirst({
where: {
token,
},
include: {
User: true,
},
});
if (!foundToken) {
throw new Error('Invalid token provided. Please try again.');
}
const now = new Date();
if (now > foundToken.expiry) {
throw new Error('Token has expired. Please try again.');
}
const isSamePassword = await compare(password, foundToken.User.password || '');
if (isSamePassword) {
throw new Error('Your new password cannot be the same as your old password.');
}
const hashedPassword = await hash(password, SALT_ROUNDS);
await prisma.$transaction([
prisma.user.update({
where: {
id: foundToken.userId,
},
data: {
password: hashedPassword,
},
}),
prisma.passwordResetToken.deleteMany({
where: {
userId: foundToken.userId,
},
}),
]);
await sendResetPassword({ userId: foundToken.userId });
};

1
packages/lib/types/font.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.ttf';

View File

@ -0,0 +1 @@
export * from '@scure/base';

View File

@ -0,0 +1,5 @@
export const generateTwitterIntent = (text: string, shareUrl: string) => {
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(
text,
)}%0A%0A${encodeURIComponent(shareUrl)}`;
};

View File

@ -0,0 +1,104 @@
import { z } from 'zod';
import {
TFeatureFlagValue,
ZFeatureFlagValueSchema,
} from '@documenso/lib/client-only/providers/feature-flag.types';
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
/**
* Evaluate whether a flag is enabled for the current user.
*
* @param flag The flag to evaluate.
* @param options See `GetFlagOptions`.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getFlag = async (
flag: string,
options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false);
return response;
};
/**
* Get all feature flags for the current user if possible.
*
* @param options See `GetFlagOptions`.
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getAllFlags = async (
options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
};
/**
* Get all feature flags for anonymous users.
*
* @returns A record of flags and their values.
*/
export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFlagValue>> => {
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
return fetch(url, {
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
};
interface GetFlagOptions {
/**
* The headers to attach to the request to evaluate flags.
*
* The authenticated user will be derived from the headers if possible.
*/
requestHeaders: Record<string, string>;
}

View File

@ -1,5 +1,5 @@
import { customAlphabet } from 'nanoid';
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
export { nanoid } from 'nanoid';

View File

@ -0,0 +1,41 @@
import { Field } from '@documenso/prisma/client';
/**
* Sort the fields by the Y position on the document.
*/
export const sortFieldsByPosition = (fields: Field[]): Field[] => {
const clonedFields: Field[] = JSON.parse(JSON.stringify(fields));
// Sort by page first, then position on page second.
return clonedFields.sort((a, b) => a.page - b.page || Number(a.positionY) - Number(b.positionY));
};
/**
* Validate whether all the provided fields are inserted.
*
* If there are any non-inserted fields it will be highlighted and scrolled into view.
*
* @returns `true` if all fields are inserted, `false` otherwise.
*/
export const validateFieldsInserted = (fields: Field[]): boolean => {
const fieldCardElements = document.getElementsByClassName('field-card-container');
// Attach validate attribute on all fields.
Array.from(fieldCardElements).forEach((element) => {
element.setAttribute('data-validate', 'true');
});
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
const firstUninsertedField = uninsertedFields[0];
const firstUninsertedFieldElement =
firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
if (firstUninsertedFieldElement) {
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return uninsertedFields.length === 0;
};

View File

@ -0,0 +1,12 @@
export const renderCustomEmailTemplate = <T extends Record<string, string>>(
template: string,
variables: T,
): string => {
return template.replace(/\{(\S+)\}/g, (_, key) => {
if (key in variables) {
return variables[key];
}
return key;
});
};

View File

@ -3,6 +3,9 @@
"version": "0.0.0",
"main": "./index.cjs",
"license": "MIT",
"scripts": {
"clean": "rimraf node_modules"
},
"dependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"prettier": "^2.8.8",

View File

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "Share" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"link" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"documentId" INTEGER,
CONSTRAINT "Share_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Share_link_key" ON "Share"("link");
-- AddForeignKey
ALTER TABLE "Share" ADD CONSTRAINT "Share_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Share" ADD CONSTRAINT "Share_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `userId` on the `Share` table. All the data in the column will be lost.
- Added the required column `recipientId` to the `Share` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Share" DROP CONSTRAINT "Share_userId_fkey";
-- AlterTable
ALTER TABLE "Share" DROP COLUMN "userId",
ADD COLUMN "recipientId" INTEGER NOT NULL;
-- AddForeignKey
ALTER TABLE "Share" ADD CONSTRAINT "Share_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,4 @@
INSERT INTO "User" ("email", "name") VALUES (
'serviceaccount@documenso.com',
'Service Account'
) ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiry" TIMESTAMP(3) NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "documentMetaId" TEXT;
-- CreateTable
CREATE TABLE "DocumentMeta" (
"id" TEXT NOT NULL,
"customEmailSubject" TEXT,
"customEmailBody" TEXT,
CONSTRAINT "DocumentMeta_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_documentMetaId_fkey" FOREIGN KEY ("documentMetaId") REFERENCES "DocumentMeta"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,35 @@
/*
Warnings:
- You are about to drop the `Share` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Share" DROP CONSTRAINT "Share_documentId_fkey";
-- DropForeignKey
ALTER TABLE "Share" DROP CONSTRAINT "Share_recipientId_fkey";
-- DropTable
DROP TABLE "Share";
-- CreateTable
CREATE TABLE "DocumentShareLink" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"documentId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DocumentShareLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DocumentShareLink_slug_key" ON "DocumentShareLink"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "DocumentShareLink_documentId_email_key" ON "DocumentShareLink"("documentId", "email");
-- AddForeignKey
ALTER TABLE "DocumentShareLink" ADD CONSTRAINT "DocumentShareLink_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[documentMetaId]` on the table `Document` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Document_documentMetaId_key" ON "Document"("documentMetaId");

View File

@ -0,0 +1,52 @@
/*
Warnings:
- You are about to drop the column `documentMetaId` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `customEmailBody` on the `DocumentMeta` table. All the data in the column will be lost.
- You are about to drop the column `customEmailSubject` on the `DocumentMeta` table. All the data in the column will be lost.
- A unique constraint covering the columns `[documentId]` on the table `DocumentMeta` will be added. If there are existing duplicate values, this will fail.
- Added the required column `documentId` to the `DocumentMeta` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Document" DROP CONSTRAINT "Document_documentMetaId_fkey";
-- DropIndex
DROP INDEX "Document_documentMetaId_key";
-- AlterTable
ALTER TABLE "DocumentMeta"
ADD COLUMN "documentId" INTEGER,
ADD COLUMN "message" TEXT,
ADD COLUMN "subject" TEXT;
-- Migrate data
UPDATE "DocumentMeta" SET "documentId" = (
SELECT "id" FROM "Document" WHERE "Document"."documentMetaId" = "DocumentMeta"."id"
);
-- Migrate data
UPDATE "DocumentMeta" SET "message" = "customEmailBody";
-- Migrate data
UPDATE "DocumentMeta" SET "subject" = "customEmailSubject";
-- Prune data
DELETE FROM "DocumentMeta" WHERE "documentId" IS NULL;
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "documentMetaId";
-- AlterTable
ALTER TABLE "DocumentMeta"
DROP COLUMN "customEmailBody",
DROP COLUMN "customEmailSubject";
-- AlterColumn
ALTER TABLE "DocumentMeta" ALTER COLUMN "documentId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "DocumentMeta_documentId_key" ON "DocumentMeta"("documentId");
-- AddForeignKey
ALTER TABLE "DocumentMeta" ADD CONSTRAINT "DocumentMeta_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -7,6 +7,7 @@
"scripts": {
"build": "prisma generate",
"format": "prisma format",
"clean": "rimraf node_modules",
"prisma:generate": "prisma generate",
"prisma:migrate-dev": "prisma migrate dev",
"prisma:migrate-deploy": "prisma migrate deploy",

View File

@ -19,19 +19,29 @@ enum Role {
}
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
emailVerified DateTime?
password String?
source String?
signature String?
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription[]
id Int @id @default(autoincrement())
name String?
email String @unique
emailVerified DateTime?
password String?
source String?
signature String?
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription[]
PasswordResetToken PasswordResetToken[]
}
model PasswordResetToken {
id Int @id @default(autoincrement())
token String @unique
createdAt DateTime @default(now())
expiry DateTime
userId Int
User User @relation(fields: [userId], references: [id])
}
enum SubscriptionStatus {
@ -91,15 +101,17 @@ enum DocumentStatus {
}
model Document {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
status DocumentStatus @default(DRAFT)
status DocumentStatus @default(DRAFT)
Recipient Recipient[]
Field Field[]
ShareLink DocumentShareLink[]
documentDataId String
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
documentMeta DocumentMeta?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@ -120,6 +132,14 @@ model DocumentData {
Document Document?
}
model DocumentMeta {
id String @id @default(cuid())
subject String?
message String?
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
}
enum ReadStatus {
NOT_OPENED
OPENED
@ -190,3 +210,16 @@ model Signature {
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
}
model DocumentShareLink {
id Int @id @default(autoincrement())
email String
slug String @unique
documentId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
document Document @relation(fields: [documentId], references: [id])
@@unique([documentId, email])
}

View File

@ -1,5 +1,6 @@
import { Document, DocumentData } from '@documenso/prisma/client';
import { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client';
export type DocumentWithData = Document & {
documentData?: DocumentData | null;
documentMeta?: DocumentMeta | null;
};

View File

@ -1,5 +1,10 @@
import { Document, Recipient } from '@documenso/prisma/client';
import { Document, DocumentData, Recipient } from '@documenso/prisma/client';
export type DocumentWithRecipient = Document & {
export type DocumentWithRecipients = Document & {
Recipient: Recipient[];
};
export type DocumentWithRecipient = Document & {
Recipient: Recipient;
documentData: DocumentData;
};

View File

@ -0,0 +1,73 @@
import signer from 'node-signpdf';
import { PDFArray, PDFDocument, PDFHexString, PDFName, PDFNumber, PDFString } from 'pdf-lib';
export type AddSigningPlaceholderOptions = {
pdf: Buffer;
};
export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOptions) => {
const doc = await PDFDocument.load(pdf);
const pages = doc.getPages();
const byteRange = PDFArray.withContext(doc.context);
byteRange.push(PDFNumber.of(0));
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
const signature = doc.context.obj({
Type: 'Sig',
Filter: 'Adobe.PPKLite',
SubFilter: 'adbe.pkcs7.detached',
ByteRange: byteRange,
Contents: PDFHexString.fromText(' '.repeat(8192)),
Reason: PDFString.of('Signed by Documenso'),
M: PDFString.fromDate(new Date()),
});
const signatureRef = doc.context.register(signature);
const widget = doc.context.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Sig',
Rect: [0, 0, 0, 0],
V: signatureRef,
T: PDFString.of('Signature1'),
F: 4,
P: pages[0].ref,
});
const widgetRef = doc.context.register(widget);
let widgets = pages[0].node.get(PDFName.of('Annots'));
if (widgets instanceof PDFArray) {
widgets.push(widgetRef);
} else {
const newWidgets = PDFArray.withContext(doc.context);
newWidgets.push(widgetRef);
pages[0].node.set(PDFName.of('Annots'), newWidgets);
widgets = pages[0].node.get(PDFName.of('Annots'));
}
if (!widgets) {
throw new Error('No widgets');
}
pages[0].node.set(PDFName.of('Annots'), widgets);
doc.catalog.set(
PDFName.of('AcroForm'),
doc.context.obj({
SigFlags: 3,
Fields: [widgetRef],
}),
);
return Buffer.from(await doc.save({ useObjectStreams: false }));
};

17
packages/signing/index.ts Normal file
View File

@ -0,0 +1,17 @@
import { match } from 'ts-pattern';
import { signWithLocalCert } from './transports/local-cert';
export type SignOptions = {
pdf: Buffer;
};
export const signPdf = async ({ pdf }: SignOptions) => {
const transport = process.env.NEXT_PRIVATE_SIGNING_TRANSPORT || 'local';
return await match(transport)
.with('local', async () => signWithLocalCert({ pdf }))
.otherwise(() => {
throw new Error(`Unsupported signing transport: ${transport}`);
});
};

View File

@ -0,0 +1,23 @@
{
"name": "@documenso/signing",
"version": "1.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "AGPLv3",
"files": [
"transports/",
"index.ts"
],
"scripts": {
},
"dependencies": {
"@documenso/tsconfig": "*",
"node-forge": "^1.3.1",
"node-signpdf": "^2.0.0",
"pdf-lib": "^1.17.1",
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"@types/node-forge": "^1.3.4"
}
}

View File

@ -0,0 +1,32 @@
import signer from 'node-signpdf';
import fs from 'node:fs';
import { addSigningPlaceholder } from '../helpers/addSigningPlaceholder';
export type SignWithLocalCertOptions = {
pdf: Buffer;
};
export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
const pdfWithPlaceholder = await addSigningPlaceholder({ pdf });
let p12Cert: Buffer | null = null;
if (process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS) {
p12Cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64');
}
if (!p12Cert) {
p12Cert = Buffer.from(
fs.readFileSync(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH || './example/cert.p12'),
);
}
if (process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE) {
return signer.sign(pdfWithPlaceholder, p12Cert, {
passphrase: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE,
});
}
return signer.sign(pdfWithPlaceholder, p12Cert);
};

View File

@ -0,0 +1,8 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"compilerOptions": {
"types": ["@documenso/tsconfig/process-env.d.ts", "@types/node"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -3,6 +3,9 @@
"version": "0.0.0",
"main": "index.cjs",
"license": "MIT",
"scripts": {
"clean": "rimraf node_modules"
},
"dependencies": {
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",

View File

@ -5,6 +5,7 @@
"types": "./index.ts",
"license": "MIT",
"scripts": {
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/lib": "*",

View File

@ -1,10 +1,17 @@
import { TRPCError } from '@trpc/server';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { authenticatedProcedure, router } from '../trpc';
import { ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema } from './schema';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZForgotPasswordFormSchema,
ZResetPasswordFormSchema,
ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema,
} from './schema';
export const profileRouter = router({
updateProfile: authenticatedProcedure
@ -53,4 +60,38 @@ export const profileRouter = router({
});
}
}),
forgotPassword: procedure.input(ZForgotPasswordFormSchema).mutation(async ({ input }) => {
try {
const { email } = input;
return await forgotPassword({
email,
});
} catch (err) {
console.error(err);
}
}),
resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input }) => {
try {
const { password, token } = input;
return await resetPassword({
token,
password,
});
} catch (err) {
let message = 'We were unable to reset your password. Please try again.';
if (err instanceof Error) {
message = err.message;
}
throw new TRPCError({
code: 'BAD_REQUEST',
message,
});
}
}),
});

View File

@ -5,10 +5,20 @@ export const ZUpdateProfileMutationSchema = z.object({
signature: z.string(),
});
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
export const ZUpdatePasswordMutationSchema = z.object({
password: z.string().min(6),
});
export const ZForgotPasswordFormSchema = z.object({
email: z.string().email().min(1),
});
export const ZResetPasswordFormSchema = z.object({
password: z.string().min(6),
token: z.string().min(1),
});
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;

View File

@ -2,6 +2,7 @@ import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router';
import { profileRouter } from './profile-router/router';
import { shareLinkRouter } from './share-link-router/router';
import { procedure, router } from './trpc';
export const appRouter = router({
@ -10,6 +11,7 @@ export const appRouter = router({
profile: profileRouter,
document: documentRouter,
field: fieldRouter,
shareLink: shareLinkRouter,
});
export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,35 @@
import { TRPCError } from '@trpc/server';
import { createOrGetShareLink } from '@documenso/lib/server-only/share/create-or-get-share-link';
import { procedure, router } from '../trpc';
import { ZCreateOrGetShareLinkMutationSchema } from './schema';
export const shareLinkRouter = router({
createOrGetShareLink: procedure
.input(ZCreateOrGetShareLinkMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const { documentId, token } = input;
if (token) {
return await createOrGetShareLink({ documentId, token });
}
if (!ctx.user?.id) {
throw new Error(
'You must either provide a token or be logged in to create a sharing link.',
);
}
return await createOrGetShareLink({ documentId, userId: ctx.user.id });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create a sharing link.',
});
}
}),
});

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZCreateOrGetShareLinkMutationSchema = z.object({
documentId: z.number(),
token: z.string().optional(),
});
export type TCreateOrGetShareLinkMutationSchema = z.infer<
typeof ZCreateOrGetShareLinkMutationSchema
>;

View File

@ -3,6 +3,9 @@
"version": "0.0.0",
"license": "MIT",
"private": true,
"scripts": {
"clean": "rimraf node_modules"
},
"files": [
"base.json",
"nextjs.json",

View File

@ -21,6 +21,12 @@ declare namespace NodeJS {
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID?: string;
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY?: string;
NEXT_PRIVATE_SIGNING_TRANSPORT?: 'local' | 'http' | 'gcloud-hsm';
NEXT_PRIVATE_SIGNING_PASSPHRASE?: string;
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING?: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;

View File

@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog';
import { LazyPDFViewerNoLoader } from '../../primitives/lazy-pdf-viewer';
export type DocumentDialogProps = {
document: string;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
/**
* A dialog which renders the provided document.
*/
export default function DocumentDialog({ document, ...props }: DocumentDialogProps) {
const [documentLoaded, setDocumentLoaded] = useState(false);
const onDocumentLoad = () => {
setDocumentLoaded(true);
};
return (
<Dialog {...props}>
<DialogPortal>
<DialogOverlay className="bg-black/80" />
<DialogPrimitive.Content
className={cn(
'animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 pointer-events-none fixed z-50 h-screen w-screen overflow-y-auto px-2 py-14 opacity-0 transition-opacity lg:py-32',
{
'opacity-100': documentLoaded,
},
)}
onClick={() => props.onOpenChange?.(false)}
>
<LazyPDFViewerNoLoader
className="mx-auto w-full max-w-3xl xl:max-w-5xl"
document={`data:application/pdf;base64,${document}`}
onClick={(e) => e.stopPropagation()}
onDocumentLoad={onDocumentLoad}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
<X className="h-6 w-6 text-white" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
}

View File

@ -0,0 +1,78 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
documentData?: DocumentData;
};
export const DocumentDownloadButton = ({
className,
fileName,
documentData,
disabled,
...props
}: DownloadButtonProps) => {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const onDownloadClick = async () => {
try {
setIsLoading(true);
if (!documentData) {
return;
}
const bytes = await getFile(documentData);
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !documentData}
onClick={onDownloadClick}
loading={isLoading}
{...props}
>
<Download className="mr-2 h-5 w-5" />
Download
</Button>
);
};

View File

@ -0,0 +1,63 @@
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { VariantProps, cva } from 'class-variance-authority';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { cn } from '@documenso/ui/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { Field } from '.prisma/client';
const tooltipVariants = cva('font-semibold', {
variants: {
color: {
default: 'border-2 fill-white',
warning: 'border-0 bg-orange-300 fill-orange-300 text-orange-900',
},
},
defaultVariants: {
color: 'default',
},
});
interface FieldToolTipProps extends VariantProps<typeof tooltipVariants> {
children: React.ReactNode;
className?: string;
field: Field;
}
/**
* Renders a tooltip for a given field.
*/
export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) {
const coords = useFieldPageCoords(field);
return createPortal(
<div
className={cn('absolute')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<TooltipProvider>
<Tooltip delayDuration={0} open={!field.inserted}>
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
{children}
<TooltipArrow />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>,
document.body,
);
}

View File

@ -0,0 +1,90 @@
'use client';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { Field } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
export type FieldRootContainerProps = {
field: Field;
children: React.ReactNode;
};
export type FieldContainerPortalProps = {
field: Field;
className?: string;
children: React.ReactNode;
};
export function FieldContainerPortal({
field,
children,
className = '',
}: FieldContainerPortalProps) {
const coords = useFieldPageCoords(field);
return createPortal(
<div
className={cn('absolute', className)}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
{children}
</div>,
document.body,
);
}
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) {
return;
}
const observer = new MutationObserver((_mutations) => {
if (ref.current) {
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
}
});
observer.observe(ref.current, {
attributes: true,
});
return () => {
observer.disconnect();
};
}, []);
return (
<FieldContainerPortal field={field}>
<Card
id={`field-${field.id}`}
className={cn(
'field-card-container bg-background relative z-20 h-full w-full transition-all',
{
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
},
)}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
{children}
</CardContent>
</Card>
</FieldContainerPortal>
);
}

View File

@ -0,0 +1,208 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Image, { StaticImageData } from 'next/image';
import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
export type SigningCardProps = {
className?: string;
name: string;
signingCelebrationImage?: StaticImageData;
};
/**
* 2D signing card.
*/
export const SigningCard = ({ className, name, signingCelebrationImage }: SigningCardProps) => {
return (
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
<SigningCardContent name={name} />
{signingCelebrationImage && (
<SigningCardImage signingCelebrationImage={signingCelebrationImage} />
)}
</div>
);
};
/**
* 3D signing card that follows the mouse movement within a certain range.
*/
export const SigningCard3D = ({ className, name, signingCelebrationImage }: SigningCardProps) => {
// Should use % based dimensions by calculating the window height/width.
const boundary = 400;
const [trackMouse, setTrackMouse] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
const cardX = useMotionValue(0);
const cardY = useMotionValue(0);
const rotateX = useTransform(cardY, [-600, 600], [8, -8]);
const rotateY = useTransform(cardX, [-600, 600], [-8, 8]);
const diagonalMovement = useTransform<number, number>(
[rotateX, rotateY],
([newRotateX, newRotateY]) => newRotateX + newRotateY,
);
const sheenPosition = useTransform(diagonalMovement, [-16, 16], [-100, 200]);
const sheenOpacity = useTransform(sheenPosition, [-100, 50, 200], [0, 0.1, 0]);
const sheenGradient = useMotionTemplate`linear-gradient(
30deg,
transparent,
rgba(var(--sheen-color) / ${trackMouse ? sheenOpacity : 0}) ${sheenPosition}%,
transparent)`;
const cardRef = useRef<HTMLDivElement>(null);
const cardCenterPosition = useCallback(() => {
if (!cardRef.current) {
return { x: 0, y: 0 };
}
const { x, y, width, height } = cardRef.current.getBoundingClientRect();
return { x: x + width / 2, y: y + height / 2 };
}, [cardRef]);
const onMouseMove = useCallback(
(event: MouseEvent) => {
const { x, y } = cardCenterPosition();
const offsetX = event.clientX - x;
const offsetY = event.clientY - y;
// Calculate distance between the mouse pointer and center of the card.
const distance = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
// Mouse enters enter boundary.
if (distance <= boundary && !trackMouse) {
setTrackMouse(true);
} else if (!trackMouse) {
return;
}
cardX.set(offsetX);
cardY.set(offsetY);
clearTimeout(timeoutRef.current);
// Revert the card back to the center position after the mouse stops moving.
timeoutRef.current = setTimeout(() => {
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
setTrackMouse(false);
}, 1000);
},
[cardX, cardY, cardCenterPosition, trackMouse],
);
useEffect(() => {
window.addEventListener('mousemove', onMouseMove);
return () => {
window.removeEventListener('mousemove', onMouseMove);
};
}, [onMouseMove]);
return (
<div
className={cn('relative w-full max-w-xs md:max-w-sm', className)}
style={{ perspective: 800 }}
>
<motion.div
className="bg-background w-full [--sheen-color:180_180_180] dark:[--sheen-color:200_200_200]"
ref={cardRef}
style={{
perspective: '800',
backgroundImage: sheenGradient,
transformStyle: 'preserve-3d',
rotateX,
rotateY,
// willChange: 'transform background-image',
}}
>
<SigningCardContent className="bg-transparent" name={name} />
</motion.div>
{signingCelebrationImage && (
<SigningCardImage signingCelebrationImage={signingCelebrationImage} />
)}
</div>
);
};
type SigningCardContentProps = {
name: string;
className?: string;
};
const SigningCardContent = ({ className, name }: SigningCardContentProps) => {
return (
<Card
className={cn(
'group mx-auto flex aspect-[21/9] w-full items-center justify-center',
className,
)}
degrees={-145}
gradient
>
<CardContent
className="font-signature p-6 text-center"
style={{
container: 'main',
}}
>
<span
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
style={{
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
}}
>
{name}
</span>
</CardContent>
</Card>
);
};
type SigningCardImageProps = {
signingCelebrationImage: StaticImageData;
};
const SigningCardImage = ({ signingCelebrationImage }: SigningCardImageProps) => {
return (
<motion.div
className="pointer-events-none absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
initial={{
opacity: 0,
scale: 0.8,
}}
animate={{
scale: 1,
opacity: 0.5,
}}
transition={{
delay: 0.5,
duration: 0.5,
}}
>
<Image
src={signingCelebrationImage}
alt="background pattern"
className="w-full dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</motion.div>
);
};

View File

@ -1,28 +1,34 @@
import { forwardRef } from 'react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
export const SignatureIcon: LucideIcon = ({
size = 24,
color = 'currentColor',
strokeWidth = 1.33,
absoluteStrokeWidth,
...props
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M1.5 11H14.5M1.5 14C1.5 14 8.72 2 4.86938 2H4.875C2.01 2 1.97437 14.0694 8 6.51188V6.5C8 6.5 9 11.3631 11.5 7.52375V7.5C11.5 7.5 11.5 9 14.5 9"
stroke={color}
strokeWidth={absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export const SignatureIcon: LucideIcon = forwardRef(
(
{ size = 24, color = 'currentColor', strokeWidth = 1.33, absoluteStrokeWidth, ...props },
ref,
) => {
return (
<svg
ref={ref}
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M1.5 11H14.5M1.5 14C1.5 14 8.72 2 4.86938 2H4.875C2.01 2 1.97437 14.0694 8 6.51188V6.5C8 6.5 9 11.3631 11.5 7.52375V7.5C11.5 7.5 11.5 9 14.5 9"
stroke={color}
strokeWidth={
absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth
}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
},
);
SignatureIcon.displayName = 'SignatureIcon';

View File

@ -12,11 +12,13 @@
"index.tsx"
],
"scripts": {
"lint": "eslint \"**/*.ts*\""
"lint": "eslint \"**/*.ts*\"",
"clean": "rimraf node_modules"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/luxon": "^3.3.2",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"react": "18.2.0",
@ -24,6 +26,7 @@
},
"dependencies": {
"@documenso/lib": "*",
"@hookform/resolvers": "^3.3.0",
"@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2",
@ -44,21 +47,25 @@
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.5",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-table": "^8.9.1",
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"next": "13.4.12",
"lucide-react": "^0.279.0",
"luxon": "^3.4.2",
"next": "13.4.19",
"pdfjs-dist": "3.6.172",
"react-day-picker": "^8.7.1",
"react-hook-form": "^7.45.4",
"react-pdf": "^7.3.3",
"react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5"
}

View File

@ -56,14 +56,14 @@ export interface ButtonProps
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
({ className, variant, size, asChild = false, loading, ...props }, ref) => {
if (asChild) {
return (
<Slot className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
const showLoader = props.loading === true;
const showLoader = loading === true;
const isDisabled = props.disabled || showLoader;
return (

View File

@ -109,6 +109,8 @@ export {
DialogContent,
DialogHeader,
DialogFooter,
DialogOverlay,
DialogTitle,
DialogDescription,
DialogPortal,
};

View File

@ -73,7 +73,7 @@ const DocumentDropzoneCardCenterVariants: Variants = {
};
export type DocumentDropzoneProps = {
className: string;
className?: string;
onDrop?: (_file: File) => void | Promise<void>;
[key: string]: unknown;
};

Some files were not shown because too many files have changed in this diff Show More