Compare commits

..

13 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 2b1b042097 chore: code changes based of review 2024-11-21 21:21:42 +00:00
Ephraim Duncan 31dc403500 Merge branch 'main' into expiry-links 2024-11-21 18:14:35 +00:00
Ephraim Duncan cfb57c8c27 Merge branch 'main' into expiry-links 2024-11-19 11:09:46 +00:00
Ephraim Atta-Duncan 2d7988f484 feat: update recipient expiry handling 2024-11-17 22:57:40 +00:00
Ephraim Atta-Duncan ba627e22c5 feat: show recipient as expired on document page view 2024-11-17 17:52:28 +00:00
Ephraim Atta-Duncan 6e9d17f8ea feat: audit logS 2024-11-17 16:56:15 +00:00
Ephraim Atta-Duncan 8491c69e8c feat: period select for expiry 2024-11-17 16:06:10 +00:00
Ephraim Atta-Duncan c422317566 feat: recipient expired on dashboard 2024-11-17 12:55:37 +00:00
Ephraim Atta-Duncan 316dbee446 feat: prevent signing when expired 2024-11-17 12:12:27 +00:00
Ephraim Atta-Duncan 79d0cd7de5 feat: use existing expiry date if available 2024-11-17 11:11:45 +00:00
Ephraim Atta-Duncan e31a10a943 feat: expiry endpoint 2024-11-17 11:02:52 +00:00
Ephraim Atta-Duncan ca2b6bea95 feat: expiry dialog 2024-11-17 09:46:41 +00:00
Ephraim Atta-Duncan 63830fb257 chore: add expired signing status 2024-11-16 09:34:59 +00:00
40 changed files with 1092 additions and 831 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ const config = {
},
swcPlugins: [['@lingui/swc-plugin', {}]],
},
reactStrictMode: false,
reactStrictMode: true,
transpilePackages: [
'@documenso/assets',
'@documenso/ee',
-5
View File
@@ -25,13 +25,9 @@
"@hookform/resolvers": "^3.1.0",
"@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3",
"@mediapipe/face_mesh": "^0.4.1633559619",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
"@tensorflow-models/face-landmarks-detection": "^1.0.6",
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
@@ -56,7 +52,6 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"react-webcam": "^7.2.0",
"recharts": "^2.7.2",
"remeda": "^2.12.1",
"sharp": "0.32.6",
@@ -12,13 +12,14 @@ import {
MailOpenIcon,
PenIcon,
PlusIcon,
Timer,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -132,6 +133,14 @@ export const DocumentPageViewRecipients = ({
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.EXPIRED && (
<Badge variant="destructive">
<Timer className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
<PopoverHover
@@ -15,9 +15,8 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -218,7 +217,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm ">
<p className="text-muted-foreground mt-2 px-4 text-sm">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>
@@ -228,8 +227,52 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
))
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
(recipient) => recipient.signingStatus === SigningStatus.NOT_SIGNED,
);
const rejectedCount = recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
).length;
const expiredCount = recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.EXPIRED,
).length;
if (rejectedCount > 0 && expiredCount > 0) {
return (
<>
<Plural
value={rejectedCount}
one="1 recipient has rejected the document"
other="# recipients have rejected the document"
/>
{' and '}
<Plural
value={expiredCount}
one="1 recipient's signing link has expired"
other="# recipients' signing links have expired"
/>
</>
);
}
if (rejectedCount > 0) {
return (
<Plural
value={rejectedCount}
one="1 recipient has rejected the document"
other="# recipients have rejected the document"
/>
);
}
if (expiredCount > 0) {
return (
<Plural
value={expiredCount}
one="1 recipient's signing link has expired"
other="# recipients' signing links have expired"
/>
);
}
return (
<Plural
@@ -419,6 +419,8 @@ export const EditDocumentForm = ({
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
documentId={document.id}
// teamId={team?.id}
/>
<AddFieldsFormPartial
@@ -100,7 +100,7 @@ export const ResendDocumentActionItem = ({
});
setIsOpen(false);
} catch (err) {
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be re-sent at this time. Please try again.`),
@@ -177,12 +177,7 @@ export const ResendDocumentActionItem = ({
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
<Button type="button" className="flex-1" variant="secondary" disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
@@ -0,0 +1,99 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { Timer } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
export type ExpiredSigningPageProps = {
params: {
token?: string;
};
};
export default async function ExpiredSigningPage({ params: { token } }: ExpiredSigningPageProps) {
await setupI18nSSR();
if (!token) {
return notFound();
}
const { user } = await getServerComponentSession();
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const recipient = await getRecipientByToken({ token }).catch(() => null);
if (!recipient) {
return notFound();
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<Timer className="text-destructive h-10 w-10" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Expired</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<Trans>This document has expired and is no longer available to sign</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
{/* TODO: send email to owner when a user tried to sign an expired document??? */}
The document owner has been notified. They may send you a new signing link if required.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link href={`/`}>Return Home</Link>
</Button>
)}
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { isRecipientExpired } from '@documenso/lib/server-only/recipient/is-recipient-expired';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -43,6 +44,16 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const isExpired = await isRecipientExpired({ token });
if (isExpired) {
return redirect(`/sign/${token}/expired`);
}
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
@@ -63,12 +74,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
@@ -8,9 +8,7 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { FieldType } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
@@ -44,10 +42,7 @@ export default async function RejectedSigningPage({ params: { token } }: Rejecte
const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
const recipient = await getRecipientByToken({ token }).catch(() => null);
if (!recipient) {
return notFound();
@@ -64,11 +59,6 @@ export default async function RejectedSigningPage({ params: { token } }: Rejecte
return <SigningAuthPageView email={recipient.email} />;
}
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
@@ -25,7 +25,6 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { NoseCanvasDrawer } from '~/components/nose-canvas-drawer';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
@@ -71,8 +70,6 @@ export const SignatureField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [isDrawing, setIsDrawing] = useState(false);
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [localSignature, setLocalSignature] = useState<string | null>(null);
@@ -228,16 +225,12 @@ export const SignatureField = ({
<Trans>Signature</Trans>
</Label>
<div className="mt-4">
<NoseCanvasDrawer
className="h-[320px]"
onStart={() => setIsDrawing(true)}
onStop={() => setIsDrawing(false)}
onCapture={(dataUrl) => {
setLocalSignature(dataUrl);
}}
/>
</div>
<SignaturePad
id="signature"
className="border-border mt-2 h-44 w-full rounded-md border"
onChange={(value) => setLocalSignature(value)}
allowTypedSignature={typedSignatureEnabled}
/>
</div>
<SigningDisclosure />
@@ -257,7 +250,7 @@ export const SignatureField = ({
<Button
type="button"
className="flex-1"
disabled={!localSignature || isDrawing}
disabled={!localSignature}
onClick={() => onDialogSignClick()}
>
<Trans>Sign</Trans>
@@ -1,10 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Nose Drawing Demo',
description: 'Draw with your nose using face detection technology',
};
export default function NoseDrawerLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -1,60 +0,0 @@
'use client';
import { useState } from 'react';
import { NoseCanvasDrawer } from '~/components/nose-canvas-drawer';
export default function NoseDrawerDemo() {
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const handleCapture = (dataUrl: string) => {
setCapturedImage(dataUrl);
};
return (
<main className="container mx-auto p-4">
<div className="mx-auto max-w-4xl">
<h1 className="mb-6 text-3xl font-bold">Nose Drawing Demo</h1>
<div className="space-y-8">
{/* Instructions */}
<div className="bg-muted rounded-lg p-4">
<h2 className="mb-2 font-semibold">How to use:</h2>
<ol className="list-inside list-decimal space-y-2">
<li>Click &quot;Play&quot; to start your camera</li>
<li>Move your nose to draw on the canvas</li>
<li>Click &quot;Export as PNG&quot; to save your drawing</li>
<li>Use &quot;Clear&quot; to start over</li>
</ol>
</div>
{/* Canvas drawer */}
<div className="bg-background rounded-lg border p-4">
<NoseCanvasDrawer onCapture={handleCapture} />
</div>
{/* Preview captured image */}
{capturedImage && (
<div className="rounded-lg border p-4">
<h2 className="mb-4 font-semibold">Captured Drawing</h2>
<img
src={capturedImage}
alt="Captured nose drawing"
className="max-w-full rounded-lg"
/>
<div className="mt-4">
<a
href={capturedImage}
download="nose-drawing.png"
className="text-primary hover:underline"
>
Download Image
</a>
</div>
</div>
)}
</div>
</div>
</main>
);
}
@@ -39,8 +39,10 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
classes = 'bg-documenso-200 text-documenso-800';
break;
case RecipientStatusType.REJECTED:
case RecipientStatusType.EXPIRED:
classes = 'bg-red-200 text-red-800';
break;
default:
break;
}
@@ -50,6 +50,10 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const expiredRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
@@ -119,6 +123,30 @@ export const StackAvatarsWithTooltip = ({
</div>
)}
{expiredRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Expired</Trans>
</h1>
{expiredRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
@@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DocumentExpirySettingsProps = {
onChange: (value: number | undefined, unit: 'day' | 'week' | 'month' | undefined) => void;
};
export const DocumentExpirySettings = ({ onChange }: DocumentExpirySettingsProps) => {
const [expiryValue, setExpiryValue] = useState<number | undefined>(undefined);
const [expiryUnit, setExpiryUnit] = useState<'day' | 'week' | 'month'>();
const { _ } = useLingui();
const handleExpiryValueChange = (value: string) => {
const parsedValue = parseInt(value, 10);
if (Number.isNaN(parsedValue) || parsedValue <= 0) {
setExpiryValue(undefined);
return;
} else {
setExpiryValue(parsedValue);
onChange(parsedValue, expiryUnit);
}
};
const handleExpiryUnitChange = (value: 'day' | 'week' | 'month') => {
setExpiryUnit(value);
onChange(expiryValue, value);
};
return (
<div className="mt-2 flex flex-row gap-4">
<Input
type="number"
placeholder={_(msg`Enter a number`)}
className="w-16"
value={expiryValue}
onChange={(e) => handleExpiryValueChange(e.target.value)}
min={1}
/>
<Select value={expiryUnit} onValueChange={handleExpiryUnitChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue placeholder={_(msg`Select...`)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">
<Trans>Day</Trans>
</SelectItem>
<SelectItem value="week">
<Trans>Week</Trans>
</SelectItem>
<SelectItem value="month">
<Trans>Month</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
);
};
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Clock, EyeOffIcon } from 'lucide-react';
import { AlertTriangle, Clock, EyeOffIcon, Timer } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
@@ -75,6 +75,9 @@ export const DocumentReadOnlyFields = ({
variant={
field.Recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: field.Recipient.signingStatus === SigningStatus.REJECTED ||
field.Recipient.signingStatus === SigningStatus.EXPIRED
? 'destructive'
: 'secondary'
}
>
@@ -83,6 +86,16 @@ export const DocumentReadOnlyFields = ({
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : field.Recipient.signingStatus === SigningStatus.REJECTED ? (
<>
<AlertTriangle className="mr-1 h-3 w-3" />
<Trans>Rejected</Trans>
</>
) : field.Recipient.signingStatus === SigningStatus.EXPIRED ? (
<>
<Timer className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</>
) : (
<>
<Clock className="mr-1 h-3 w-3" />
@@ -1,267 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import * as faceLandmarksDetection from '@tensorflow-models/face-landmarks-detection';
import * as tf from '@tensorflow/tfjs';
import '@tensorflow/tfjs-backend-webgl';
import { Play, Square, X } from 'lucide-react';
import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand';
import Webcam from 'react-webcam';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { getSvgPathFromStroke } from '@documenso/ui/primitives/signature-pad/helper';
export type NoseCanvasDrawerProps = {
className?: string;
onStart?: () => void;
onStop?: () => void;
onCapture?: (dataUrl: string) => void;
};
export const NoseCanvasDrawer = ({
className,
onStart,
onStop,
onCapture,
}: NoseCanvasDrawerProps) => {
const $el = useRef<HTMLDivElement>(null);
const $webcam = useRef<Webcam>(null);
const $canvas = useRef<HTMLCanvasElement>(null);
const $detector = useRef<faceLandmarksDetection.FaceLandmarksDetector | null>(null);
const $animationFrameId = useRef<number | null>(null);
const $previousNosePosition = useRef<{ x: number; y: number } | null>(null);
const $lines = useRef<{ x: number; y: number }[]>([]);
const $scaleFactor = useRef(1);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const onTogglePlayingClick = () => {
setIsPlaying((playing) => {
if (playing && $animationFrameId.current) {
cancelAnimationFrame($animationFrameId.current);
if ($canvas.current) {
const ctx = $canvas.current.getContext('2d');
if (ctx) {
ctx.save();
onCapture?.($canvas.current.toDataURL('image/png'));
}
$lines.current = [];
}
}
return !playing;
});
};
const onClearClick = () => {
if (isPlaying) {
return;
}
if ($canvas.current) {
const ctx = $canvas.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, $canvas.current.width, $canvas.current.height);
ctx.save();
onCapture?.($canvas.current.toDataURL('image/png'));
}
}
$lines.current = [];
};
const loadModel = async () => {
await tf.ready();
return await faceLandmarksDetection.createDetector(
faceLandmarksDetection.SupportedModels.MediaPipeFaceMesh,
{
runtime: 'mediapipe',
solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh',
refineLandmarks: true,
maxFaces: 1,
},
);
};
const detectAndDraw = async () => {
if (!$detector.current || !$canvas.current) {
return;
}
const canvas = $canvas.current;
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const video = $webcam.current?.video;
if (!video) {
return;
}
if (!isPlaying) {
return;
}
console.log('about to predict');
const predictions = await $detector.current.estimateFaces(video, {
flipHorizontal: true,
staticImageMode: false,
});
console.log({ predictions });
if (predictions.length > 0) {
const keypoints = predictions[0].keypoints;
const nose = keypoints[1]; // Nose tip keypoint
const currentPosition = {
x: nose.x * $scaleFactor.current,
y: nose.y * $scaleFactor.current,
};
if ($previousNosePosition.current) {
$lines.current.push(currentPosition);
ctx.restore();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = 'red';
const strokeOptions: StrokeOptions = {
size: 5,
thinning: 0.25,
streamline: 0.5,
smoothing: 0.5,
end: {
taper: 5,
},
};
const pathData = new Path2D(getSvgPathFromStroke(getStroke($lines.current, strokeOptions)));
ctx.fill(pathData);
ctx.save();
}
$previousNosePosition.current = currentPosition;
} else {
$previousNosePosition.current = null;
}
$animationFrameId.current = requestAnimationFrame(() => void detectAndDraw());
};
useEffect(() => {
setIsLoading(true);
void loadModel().then((model) => {
$detector.current = model;
setIsLoading(false);
});
}, []);
useEffect(() => {
if (isPlaying) {
void detectAndDraw();
onStart?.();
} else {
onStop?.();
}
}, [isPlaying]);
useEffect(() => {
if (!$webcam.current?.video) {
return;
}
const observer = new ResizeObserver((_entries) => {
if ($webcam.current?.video) {
const videoWidth = $webcam.current.video.videoWidth;
const videoHeight = $webcam.current.video.videoHeight;
const { width, height } = $webcam.current.video.getBoundingClientRect();
$scaleFactor.current = Math.min(width / videoWidth, height / videoHeight);
setIsPlaying(false);
if ($animationFrameId.current) {
cancelAnimationFrame($animationFrameId.current);
}
onClearClick();
if ($canvas.current) {
console.log('resizing canvas');
$canvas.current.width = width;
$canvas.current.height = height;
const ctx = $canvas.current.getContext('2d');
if (ctx) {
ctx.moveTo(0, 0);
ctx.save();
ctx.scale(-1, 1);
ctx.drawImage($webcam.current.video, 0, 0, width, height);
ctx.restore();
}
}
}
});
observer.observe($webcam.current.video);
return () => {
observer.disconnect();
};
}, []);
return (
<div ref={$el} className={cn('relative inline-block aspect-[4/3] h-full', className)}>
<Webcam ref={$webcam} videoConstraints={{ facingMode: 'user' }} className="scale-x-[-1]" />
<canvas ref={$canvas} className="absolute inset-0 z-10" />
<div className="absolute bottom-2 right-2 z-20 flex items-center gap-x-2">
<Button
disabled={isLoading}
onClick={onTogglePlayingClick}
className="text-primary-foreground/80 h-8 w-8 rounded-full p-0"
>
{isPlaying ? <Square className="h-4 w-4" /> : <Play className="-mr-0.5 h-4 w-4" />}
</Button>
<Button
disabled={isLoading || isPlaying}
onClick={onClearClick}
className="text-primary-foreground/80 h-8 w-8 rounded-full p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
};
-3
View File
@@ -1,3 +0,0 @@
.mirror {
transform: scaleX(-1);
}
+2 -391
View File
@@ -454,13 +454,9 @@
"@hookform/resolvers": "^3.1.0",
"@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3",
"@mediapipe/face_mesh": "^0.4.1633559619",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
"@tensorflow-models/face-landmarks-detection": "^1.0.6",
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
@@ -485,7 +481,6 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"react-webcam": "^7.2.0",
"recharts": "^2.7.2",
"remeda": "^2.12.1",
"sharp": "0.32.6",
@@ -4899,19 +4894,6 @@
"react": ">=16"
}
},
"node_modules/@mediapipe/face_detection": {
"version": "0.4.1646425229",
"resolved": "https://registry.npmjs.org/@mediapipe/face_detection/-/face_detection-0.4.1646425229.tgz",
"integrity": "sha512-aeCN+fRAojv9ch3NXorP6r5tcGVLR3/gC1HmtqB0WEZBRXrdP6/3W/sGR0dHr1iT6ueiK95G9PVjbzFosf/hrg==",
"license": "Apache-2.0",
"peer": true
},
"node_modules/@mediapipe/face_mesh": {
"version": "0.4.1633559619",
"resolved": "https://registry.npmjs.org/@mediapipe/face_mesh/-/face_mesh-0.4.1633559619.tgz",
"integrity": "sha512-Vc8cdjxS5+O2gnjWH9KncYpUCVXT0h714KlWAsyqJvJbIgUJBqpppbIx8yWcAzBDxm/5cYSuBI5p5ySIPxzcEg==",
"license": "Apache-2.0"
},
"node_modules/@messageformat/parser": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.0.tgz",
@@ -10176,328 +10158,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tensorflow-models/face-detection": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tensorflow-models/face-detection/-/face-detection-1.0.3.tgz",
"integrity": "sha512-4Ld/vFF8MrdFdrMWhlLKZD4hMW0PNY9OkYeqoCPNZ+LwFyenxAqVaNaWrR8JKp37vw9Nuzp4ILbkal5zPUnA0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"rimraf": "^3.0.2",
"tslib": "2.4.0"
},
"peerDependencies": {
"@mediapipe/face_detection": "~0.4.0",
"@tensorflow/tfjs-backend-webgl": "^4.21.0",
"@tensorflow/tfjs-converter": "^4.21.0",
"@tensorflow/tfjs-core": "^4.21.0"
}
},
"node_modules/@tensorflow-models/face-detection/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@tensorflow-models/face-detection/node_modules/tslib": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"license": "0BSD",
"peer": true
},
"node_modules/@tensorflow-models/face-landmarks-detection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@tensorflow-models/face-landmarks-detection/-/face-landmarks-detection-1.0.6.tgz",
"integrity": "sha512-CwcKcTwk/7PZ5f+9POi6dJV1osa6FvpxPduW9zw/6q0AmMhbdexTZ17qzG9SXdPONuakV1fPaiZNkXXXUDajdw==",
"license": "Apache-2.0",
"dependencies": {
"rimraf": "^3.0.2"
},
"peerDependencies": {
"@mediapipe/face_mesh": "~0.4.0",
"@tensorflow-models/face-detection": "^1.0.3",
"@tensorflow/tfjs-backend-webgl": "^4.13.0",
"@tensorflow/tfjs-converter": "^4.13.0",
"@tensorflow/tfjs-core": "^4.13.0"
}
},
"node_modules/@tensorflow-models/face-landmarks-detection/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@tensorflow/tfjs": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-4.22.0.tgz",
"integrity": "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==",
"license": "Apache-2.0",
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@tensorflow/tfjs-backend-webgl": "4.22.0",
"@tensorflow/tfjs-converter": "4.22.0",
"@tensorflow/tfjs-core": "4.22.0",
"@tensorflow/tfjs-data": "4.22.0",
"@tensorflow/tfjs-layers": "4.22.0",
"argparse": "^1.0.10",
"chalk": "^4.1.0",
"core-js": "3.29.1",
"regenerator-runtime": "^0.13.5",
"yargs": "^16.0.3"
},
"bin": {
"tfjs-custom-module": "dist/tools/custom_module/cli.js"
}
},
"node_modules/@tensorflow/tfjs-backend-cpu": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz",
"integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==",
"license": "Apache-2.0",
"dependencies": {
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-backend-webgl": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz",
"integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==",
"license": "Apache-2.0",
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@types/offscreencanvas": "~2019.3.0",
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-converter": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz",
"integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==",
"license": "Apache-2.0",
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-core": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz",
"integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==",
"license": "Apache-2.0",
"dependencies": {
"@types/long": "^4.0.1",
"@types/offscreencanvas": "~2019.7.0",
"@types/seedrandom": "^2.4.28",
"@webgpu/types": "0.1.38",
"long": "4.0.0",
"node-fetch": "~2.6.1",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
}
},
"node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@tensorflow/tfjs-core/node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0"
},
"node_modules/@tensorflow/tfjs-core/node_modules/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/@tensorflow/tfjs-data": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-4.22.0.tgz",
"integrity": "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==",
"license": "Apache-2.0",
"dependencies": {
"@types/node-fetch": "^2.1.2",
"node-fetch": "~2.6.1",
"string_decoder": "^1.3.0"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0",
"seedrandom": "^3.0.5"
}
},
"node_modules/@tensorflow/tfjs-data/node_modules/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/@tensorflow/tfjs-layers": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-4.22.0.tgz",
"integrity": "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==",
"license": "Apache-2.0 AND MIT",
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@tensorflow/tfjs/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/@tensorflow/tfjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/@tensorflow/tfjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@tensorflow/tfjs/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/@tensorflow/tfjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@tensorflow/tfjs/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@tensorflow/tfjs/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"license": "MIT",
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@theguild/remark-mermaid": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
@@ -11524,12 +11184,6 @@
"@types/node": "*"
}
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.5.tgz",
@@ -11574,6 +11228,7 @@
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz",
"integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==",
"dev": true,
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.0"
@@ -11593,12 +11248,6 @@
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="
},
"node_modules/@types/offscreencanvas": {
"version": "2019.3.0",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==",
"license": "MIT"
},
"node_modules/@types/papaparse": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz",
@@ -11743,12 +11392,6 @@
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
},
"node_modules/@types/seedrandom": {
"version": "2.4.34",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz",
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==",
"license": "MIT"
},
"node_modules/@types/semver": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
@@ -12160,12 +11803,6 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.38",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz",
"integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==",
"license": "BSD-3-Clause"
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -14950,17 +14587,6 @@
"toggle-selection": "^1.0.6"
}
},
"node_modules/core-js": {
"version": "3.29.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.1.tgz",
"integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-pure": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.35.0.tgz",
@@ -29171,16 +28797,6 @@
"react-dom": ">=15.0.0"
}
},
"node_modules/react-webcam": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/react-webcam/-/react-webcam-7.2.0.tgz",
"integrity": "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.2.0",
"react-dom": ">=16.2.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -31035,12 +30651,6 @@
"node": ">=4"
}
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
@@ -35759,6 +35369,7 @@
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"dev": true,
"engines": {
"node": ">=10"
}
@@ -7,6 +7,7 @@ export enum RecipientStatusType {
WAITING = 'waiting',
UNSIGNED = 'unsigned',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export const getRecipientType = (recipient: Recipient) => {
@@ -36,6 +37,10 @@ export const getRecipientType = (recipient: Recipient) => {
return RecipientStatusType.WAITING;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return RecipientStatusType.EXPIRED;
}
return RecipientStatusType.UNSIGNED;
};
@@ -54,5 +59,9 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
return RecipientStatusType.WAITING;
}
if (types.includes(RecipientStatusType.EXPIRED)) {
return RecipientStatusType.EXPIRED;
}
return RecipientStatusType.COMPLETED;
};
@@ -8,14 +8,15 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email';
import { updateExpiredRecipients } from './update-expired-recipients';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -61,12 +62,22 @@ export const completeDocumentWithToken = async ({
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
}
await updateExpiredRecipients(documentId);
const [recipient] = document.Recipient;
if (recipient.expired && recipient.expired < new Date()) {
throw new Error(`Recipient ${recipient.id} signature period has expired`);
}
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
throw new Error(`Recipient ${recipient.id} signature period has expired`);
}
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
@@ -14,8 +14,8 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -166,6 +166,22 @@ export const resendDocument = async ({
await prisma.$transaction(
async (tx) => {
if (recipient.expired) {
const durationInMs = recipient.expired.getTime() - document.updatedAt.getTime();
const newExpiryDate = new Date(Date.now() + durationInMs);
await tx.recipient.update({
where: { id: recipient.id },
data: {
expired: newExpiryDate,
signingStatus:
recipient.signingStatus === SigningStatus.EXPIRED
? SigningStatus.NOT_SIGNED
: recipient.signingStatus,
},
});
}
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
@@ -0,0 +1,55 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export const updateExpiredRecipients = async (documentId: number) => {
const now = new Date();
const expiredRecipients = await prisma.recipient.findMany({
where: {
documentId,
expired: {
lt: now,
},
signingStatus: {
not: SigningStatus.EXPIRED,
},
},
});
if (expiredRecipients.length > 0) {
await prisma.recipient.updateMany({
where: {
id: {
in: expiredRecipients.map((recipient) => recipient.id),
},
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
await prisma.documentAuditLog.createMany({
data: expiredRecipients.map((recipient) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
},
data: {
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
recipientEmail: recipient.email,
},
}),
),
});
}
return expiredRecipients;
};
@@ -0,0 +1,36 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type IsRecipientExpiredOptions = {
token: string;
};
export const isRecipientExpired = async ({ token }: IsRecipientExpiredOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
const now = DateTime.now();
const hasExpired = recipient.expired && DateTime.fromJSDate(recipient.expired) <= now;
if (hasExpired && recipient.signingStatus !== SigningStatus.EXPIRED) {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
}
return hasExpired;
};
@@ -0,0 +1,112 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
export type SetRecipientExpiryOptions = {
documentId: number;
recipientId: number;
expiry: Date;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const setRecipientExpiry = async ({
documentId,
recipientId,
expiry,
userId,
teamId,
requestMetadata,
}: SetRecipientExpiryOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const updatedRecipient = await prisma.$transaction(async (tx) => {
const persisted = await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
expired: new Date(expiry),
},
});
const changes = diffRecipientChanges(recipient, persisted);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user: {
id: team?.id ?? user.id,
name: team?.name ?? user.name,
email: team ? '' : user.email,
},
requestMetadata,
data: {
changes,
recipientId,
recipientEmail: persisted.email,
recipientName: persisted.name,
recipientRole: persisted.role,
},
}),
});
}
return persisted;
});
return updatedRecipient;
};
+17 -1
View File
@@ -34,6 +34,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document.
'DOCUMENT_RECIPIENT_EXPIRED', // When the recipient cannot access the document anymore.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
@@ -65,6 +66,7 @@ export const ZRecipientDiffTypeSchema = z.enum([
'EMAIL',
'ACCESS_AUTH',
'ACTION_AUTH',
'EXPIRY',
]);
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
@@ -146,12 +148,17 @@ export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
});
export const ZRecipientDiffExpirySchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.EXPIRY),
});
export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [
ZRecipientDiffActionAuthSchema,
ZRecipientDiffAccessAuthSchema,
ZRecipientDiffNameSchema,
ZRecipientDiffRoleSchema,
ZRecipientDiffEmailSchema,
ZRecipientDiffExpirySchema,
]);
const ZBaseFieldEventDataSchema = z.object({
@@ -365,7 +372,7 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
});
/**
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
* Event: Document recipient rejected the document
*/
export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED),
@@ -374,6 +381,14 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
}),
});
/**
* Event: Recipient expired
*/
export const ZDocumentAuditLogEventDocumentRecipientExpiredSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document sent.
*/
@@ -499,6 +514,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
ZDocumentAuditLogEventDocumentRecipientExpiredSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
+26 -3
View File
@@ -1,5 +1,6 @@
import type { I18n } from '@lingui/core';
import { type I18n, i18n } from '@lingui/core';
import { msg } from '@lingui/macro';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
@@ -73,7 +74,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
return data.data;
};
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions'>;
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions' | 'expired'>;
export const diffRecipientChanges = (
oldRecipient: PartialRecipient,
@@ -131,6 +132,18 @@ export const diffRecipientChanges = (
});
}
if (oldRecipient.expired !== newRecipient.expired) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.EXPIRY,
from: DateTime.fromJSDate(oldRecipient.expired!)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toLocaleString(DateTime.DATETIME_FULL),
to: DateTime.fromJSDate(newRecipient.expired!)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toLocaleString(DateTime.DATETIME_FULL),
});
}
return diffs;
};
@@ -349,7 +362,7 @@ export const formatDocumentAuditLogAction = (
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} rejected the document`;
@@ -359,6 +372,16 @@ export const formatDocumentAuditLogAction = (
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, () => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName}'s signing period has expired`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
+1
View File
@@ -394,6 +394,7 @@ enum SigningStatus {
NOT_SIGNED
SIGNED
REJECTED
EXPIRED
}
enum RecipientRole {
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
import { setRecipientExpiry } from '@documenso/lib/server-only/recipient/set-recipient-expiry';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -12,6 +13,7 @@ import {
ZAddTemplateSignersMutationSchema,
ZCompleteDocumentWithTokenMutationSchema,
ZRejectDocumentWithTokenMutationSchema,
ZSetSignerExpirySchema,
} from './schema';
export const recipientRouter = router({
@@ -45,6 +47,30 @@ export const recipientRouter = router({
}
}),
setSignerExpiry: authenticatedProcedure
.input(ZSetSignerExpirySchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, signerId, expiry, teamId } = input;
return await setRecipientExpiry({
documentId,
recipientId: signerId,
expiry,
teamId,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (error) {
console.log(error);
throw new TRPCError({
code: 'BAD_REQUEST',
message: "We're unable to set the expiry for this signer. Please try again later.",
});
}
}),
addTemplateSigners: authenticatedProcedure
.input(ZAddTemplateSignersMutationSchema)
.mutation(async ({ input, ctx }) => {
@@ -80,3 +80,12 @@ export const ZRejectDocumentWithTokenMutationSchema = z.object({
export type TRejectDocumentWithTokenMutationSchema = z.infer<
typeof ZRejectDocumentWithTokenMutationSchema
>;
export const ZSetSignerExpirySchema = z.object({
documentId: z.number(),
signerId: z.number(),
expiry: z.date().min(new Date(), { message: 'Expiry date must be in the future' }),
teamId: z.number().optional(),
});
export type TSetSignerExpirySchema = z.infer<typeof ZSetSignerExpirySchema>;
+16
View File
@@ -0,0 +1,16 @@
import { differenceInDays, differenceInMonths, differenceInWeeks } from 'date-fns';
export const calculatePeriod = (expiryDate: Date) => {
const now = new Date();
const daysDiff = differenceInDays(expiryDate, now);
const weeksDiff = differenceInWeeks(expiryDate, now);
const monthsDiff = differenceInMonths(expiryDate, now);
if (monthsDiff > 0) {
return { amount: monthsDiff, unit: 'months' as const };
} else if (weeksDiff > 0) {
return { amount: weeksDiff, unit: 'weeks' as const };
} else {
return { amount: daysDiff, unit: 'days' as const };
}
};
+12 -4
View File
@@ -29,17 +29,25 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md',
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
),
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside: 'text-muted-foreground opacity-50',
day_outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
+8 -8
View File
@@ -135,13 +135,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogOverlay,
DialogTitle,
DialogDescription,
DialogPortal,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -8,7 +8,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { motion } from 'framer-motion';
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
import { GripVerticalIcon, Plus } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -41,6 +41,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import { SignerActionDropdown } from './signer-action-dropdown';
import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = {
@@ -51,6 +52,7 @@ export type AddSignersFormProps = {
isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
documentId: number;
};
export const AddSignersFormPartial = ({
@@ -61,6 +63,7 @@ export const AddSignersFormPartial = ({
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
documentId,
}: AddSignersFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -81,6 +84,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
expiry: undefined,
actionAuth: undefined,
},
];
@@ -97,6 +101,7 @@ export const AddSignersFormPartial = ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
expiry: recipient.expired ?? undefined,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
@@ -181,6 +186,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
expiry: undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
@@ -215,6 +221,7 @@ export const AddSignersFormPartial = ({
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
expiry: undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
}
@@ -252,6 +259,7 @@ export const AddSignersFormPartial = ({
'email',
'name',
'role',
'expiry',
'signingOrder',
'actionAuth',
];
@@ -628,24 +636,20 @@ export const AddSignersFormPartial = ({
)}
/>
<button
type="button"
className={cn(
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
{
'mb-6': form.formState.errors.signers?.[index],
},
)}
disabled={
<SignerActionDropdown
className={cn({
'mb-6': form.formState.errors.signers?.[index],
})}
onDelete={() => onRemoveSigner(index)}
signer={signer}
documentId={documentId}
deleteDisabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.nativeId) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-4 w-4" />
</button>
/>
</div>
</motion.fieldset>
</div>
@@ -6,24 +6,23 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
export const ZAddSignerSchema = z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
expiry: z.date().min(new Date(), { message: 'Expiry date must be in the future' }).optional(),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
});
export const ZAddSignersFormSchema = z
.object({
signers: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
signers: z.array(ZAddSignerSchema),
signingOrder: z.nativeEnum(DocumentSigningOrder),
})
.refine(
@@ -37,3 +36,4 @@ export const ZAddSignersFormSchema = z
);
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
export type TAddSignerSchema = z.infer<typeof ZAddSignerSchema>;
@@ -0,0 +1,335 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { addDays, addMonths, addWeeks, format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Calendar } from '@documenso/ui/primitives/calendar';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { calculatePeriod } from '../../lib/calculate-period';
import { cn } from '../../lib/utils';
import { useToast } from '../use-toast';
import type { TAddSignerSchema as Signer } from './add-signers.types';
const dateFormSchema = z.object({
expiry: z.date({
required_error: 'Please select an expiry date.',
}),
});
const periodFormSchema = z.object({
amount: z.number().min(1, 'Please enter a number greater than 0.'),
unit: z.enum(['days', 'weeks', 'months']),
});
type DocumentExpiryDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
signer: Signer;
documentId: number;
};
export function DocumentExpiryDialog({
open,
onOpenChange,
signer,
documentId,
}: DocumentExpiryDialogProps) {
const { _ } = useLingui();
const router = useRouter();
const { toast } = useToast();
const [activeTab, setActiveTab] = useState<'date' | 'period'>('date');
const dateForm = useForm<z.infer<typeof dateFormSchema>>({
resolver: zodResolver(dateFormSchema),
defaultValues: {
expiry: signer.expiry,
},
});
const periodForm = useForm<z.infer<typeof periodFormSchema>>({
resolver: zodResolver(periodFormSchema),
defaultValues: signer.expiry
? calculatePeriod(signer.expiry)
: {
amount: undefined,
unit: undefined,
},
});
const watchAmount = periodForm.watch('amount');
const watchUnit = periodForm.watch('unit');
const { mutateAsync: setSignerExpiry, isLoading } = trpc.recipient.setSignerExpiry.useMutation({
onSuccess: (updatedRecipient) => {
router.refresh();
periodForm.reset(
updatedRecipient?.expired
? calculatePeriod(updatedRecipient.expired)
: {
amount: undefined,
unit: undefined,
},
);
dateForm.reset(
{
expiry: updatedRecipient?.expired ?? undefined,
},
{
keepValues: false,
},
);
toast({
title: _(msg`Signer Expiry Set`),
description: _(msg`The expiry date for the signer has been set.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: _(msg`Error`),
description: error.message || _(msg`An error occurred while setting the expiry date.`),
variant: 'destructive',
duration: 7500,
});
},
});
const onSetExpiry = async (
values: z.infer<typeof dateFormSchema> | z.infer<typeof periodFormSchema>,
) => {
if (!signer.nativeId) {
return toast({
title: _(msg`Error`),
description: _(msg`An error occurred while setting the expiry date.`),
variant: 'destructive',
duration: 7500,
});
}
let expiryDate: Date;
if ('expiry' in values) {
expiryDate = values.expiry;
} else {
const now = new Date();
switch (values.unit) {
case 'days':
expiryDate = addDays(now, values.amount);
break;
case 'weeks':
expiryDate = addWeeks(now, values.amount);
break;
case 'months':
expiryDate = addMonths(now, values.amount);
break;
default:
throw new Error(`Invalid unit: ${values.unit}`);
}
}
await setSignerExpiry({
documentId,
signerId: signer.nativeId,
expiry: expiryDate,
});
// TODO: Duncan => Implement logic to update expiry when resending document
// This should be handled on the server-side when a document is resent
// TODO: Duncan => Implement logic to mark recipients as expired
// This should be a scheduled task or part of the completion process on the server
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>Set Recipient Expiry</DialogTitle>
<DialogDescription>
Set the expiry date for the document signing recipient. The recipient will not be able
to sign the document after this date.
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return setActiveTab(value as 'date' | 'period');
}}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="date">Specific Date</TabsTrigger>
<TabsTrigger value="period">Time Period</TabsTrigger>
</TabsList>
<TabsContent value="date">
<Form {...dateForm}>
<form onSubmit={dateForm.handleSubmit(onSetExpiry)} className="space-y-8">
<FormField
control={dateForm.control}
name="expiry"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Expiry Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={'outline'}
className={cn(
'w-[240px] pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? format(field.value, 'PPP') : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="z-[1100] w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) => date < new Date() || date < new Date('1900-01-01')}
initialFocus
/>
</PopoverContent>
</Popover>
<FormDescription>
The document will expire at 11:59 PM on the selected date.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isLoading}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</TabsContent>
<TabsContent value="period">
<Form {...periodForm}>
<form onSubmit={periodForm.handleSubmit(onSetExpiry)} className="space-y-8">
<div className="flex space-x-4">
<FormField
control={periodForm.control}
name="amount"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Amount</FormLabel>
<FormControl>
<Select
onValueChange={(value) => field.onChange(parseInt(value, 10))}
value={watchAmount?.toString()}
>
<SelectTrigger>
<SelectValue placeholder="Select amount" />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={periodForm.control}
name="unit"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Unit</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={watchUnit}>
{' '}
<SelectTrigger>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="weeks">Weeks</SelectItem>
<SelectItem value="months">Months</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormDescription>
The document will expire after the selected time period from now.
</FormDescription>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isLoading}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
@@ -36,7 +36,7 @@ export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
return createPortal(
<div
className={cn('pointer-events-none absolute z-10 opacity-75')}
className={cn('pointer-events-none absolute opacity-75')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { MoreHorizontal, Timer, Trash } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { cn } from '../../lib/utils';
import type { TAddSignerSchema as Signer } from './add-signers.types';
import { DocumentExpiryDialog } from './document-expiry-dialog';
type SignerActionDropdownProps = {
onDelete: () => void;
deleteDisabled?: boolean;
className?: string;
signer: Signer;
documentId: number;
};
export function SignerActionDropdown({
deleteDisabled,
className,
signer,
documentId,
onDelete,
}: SignerActionDropdownProps) {
const [isExpiryDialogOpen, setExpiryDialogOpen] = useState(false);
return (
<>
<div className={cn('flex items-center justify-center', className)}>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuItem className="gap-x-2" onClick={() => setExpiryDialogOpen(true)}>
<Timer className="h-4 w-4" />
Expiry
</DropdownMenuItem>
<DropdownMenuItem disabled={deleteDisabled} className="gap-x-2" onClick={onDelete}>
<Trash className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DocumentExpiryDialog
open={isExpiryDialogOpen}
onOpenChange={setExpiryDialogOpen}
signer={signer}
documentId={documentId}
/>
</>
);
}
+1 -1
View File
@@ -92,4 +92,4 @@ const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) =>
);
};
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
export { Popover, PopoverContent, PopoverHover, PopoverTrigger };