Merge branch 'main' into chore/status-widget-new

This commit is contained in:
Ephraim Duncan
2024-04-05 12:03:16 +00:00
committed by GitHub
75 changed files with 1984 additions and 1258 deletions

View File

@ -40,16 +40,6 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport. # OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS= NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# [[SIGNING]]
# OPTIONAL: Defines the signing transport to use. Available options: local (default)
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: Defines the passphrase for the signing certificate.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
# [[STORAGE]] # [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database" NEXT_PUBLIC_UPLOAD_TRANSPORT="database"

View File

@ -2,6 +2,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { withContentlayer } = require('next-contentlayer'); const { withContentlayer } = require('next-contentlayer');
const { withAxiom } = require('next-axiom');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@ -95,4 +96,4 @@ const config = {
}, },
}; };
module.exports = withContentlayer(config); module.exports = withAxiom(withContentlayer(config));

View File

@ -27,6 +27,7 @@
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "14.0.3", "next": "14.0.3",
"next-auth": "4.24.5", "next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-contentlayer": "^0.3.4", "next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",

View File

@ -2,6 +2,7 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google'; import { Caveat, Inter } from 'next/font/google';
import { AxiomWebVitals } from 'next-axiom';
import { PublicEnvScript } from 'next-runtime-env'; import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
@ -67,6 +68,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<PublicEnvScript /> <PublicEnvScript />
</head> </head>
<AxiomWebVitals />
<Suspense> <Suspense>
<PostHogPageview /> <PostHogPageview />
</Suspense> </Suspense>

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';
@ -51,7 +51,7 @@ export const ShareConnectPaidWidgetBento = ({
<Card className="col-span-2 lg:col-span-1" spotlight> <Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6"> <CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed"> <p className="text-foreground/80 leading-relaxed">
<strong className="block">Connections (Soon).</strong> <strong className="block">Connections</strong>
Create connections and automations with Zapier and more to integrate with your Create connections and automations with Zapier and more to integrate with your
favorite tools. favorite tools.
</p> </p>

View File

@ -2,6 +2,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { version } = require('./package.json'); const { version } = require('./package.json');
const { withAxiom } = require('next-axiom');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@ -91,4 +92,4 @@ const config = {
}, },
}; };
module.exports = config; module.exports = withAxiom(config);

View File

@ -33,8 +33,10 @@
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "14.0.3", "next": "14.0.3",
"next-auth": "4.24.5", "next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"papaparse": "^5.4.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3", "posthog-js": "^1.75.3",
"posthog-node": "^3.1.1", "posthog-node": "^3.1.1",
@ -58,6 +60,7 @@
"@types/formidable": "^2.0.6", "@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/node": "20.1.0", "@types/node": "20.1.0",
"@types/papaparse": "^5.3.14",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",

View File

@ -58,6 +58,7 @@ export const UsersDataTable = ({
perPage, perPage,
}); });
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchString]); }, [debouncedSearchString]);
const onPaginationChange = (page: number, perPage: number) => { const onPaginationChange = (page: number, perPage: number) => {

View File

@ -38,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreatePasskeyDialogProps = { export type CreatePasskeyDialogProps = {
trigger?: React.ReactNode; trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({ const ZCreatePasskeyFormSchema = z.object({
@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
const parser = new UAParser(); const parser = new UAParser();
export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr
duration: 5000, duration: 5000,
}); });
onSuccess?.();
setOpen(false); setOpen(false);
} catch (err) { } catch (err) {
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {

View File

@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { RecipientRole } from '@documenso/prisma/client';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuth2FAProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const Z2FAAuthFormSchema = z.object({
token: z
.string()
.min(4, { message: 'Token must at least 4 characters long' })
.max(10, { message: 'Token must be at most 10 characters long' }),
});
type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
export const DocumentActionAuth2FA = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentActionAuth2FAProps) => {
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentAuthContext();
const form = useForm<T2FAAuthFormSchema>({
resolver: zodResolver(Z2FAAuthFormSchema),
defaultValues: {
token: '',
},
});
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
await onReauthFormSubmit({
type: DocumentAuth.TWO_FACTOR_AUTH,
token,
});
setIsCurrentlyAuthenticating(false);
onOpenChange(false);
} catch (err) {
setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
useEffect(() => {
form.reset({
token: '',
});
setIs2FASetupSuccessful(false);
setFormErrorCode(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
<p>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup 2FA to mark this document as viewed.'
: `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
</p>
{user?.identityProvider === 'DOCUMENSO' && (
<p className="mt-2">
By enabling 2FA, you will be required to enter a code from your authenticator app
every time you sign in.
</p>
)}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
<EnableAuthenticatorAppDialog onSuccess={() => setIs2FASetupSuccessful(true)} />
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>2FA token</FormLabel>
<FormControl>
<Input {...field} placeholder="Token" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please try again or contact support
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,79 @@
import { useState } from 'react';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuthAccountProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
onOpenChange: (value: boolean) => void;
};
export const DocumentActionAuthAccount = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onOpenChange,
}: DocumentActionAuthAccountProps) => {
const { recipient } = useRequiredDocumentAuthContext();
const [isSigningOut, setIsSigningOut] = useState(false);
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
const encryptedEmail = await encryptSecondaryData({
data: email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
await signOut({
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
});
} catch {
setIsSigningOut(false);
// Todo: Alert.
}
};
return (
<fieldset disabled={isSigningOut} className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</span>
) : (
<span>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
in as <strong>{recipient.email}</strong>
</span>
)}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={async () => handleChangeAccount(recipient.email)} loading={isSigningOut}>
Login
</Button>
</DialogFooter>
</fieldset>
);
};

View File

@ -1,13 +1,4 @@
/** import { P, match } from 'ts-pattern';
* Note: This file has some commented out stuff for password auth which is no longer possible.
*
* Leaving it here until after we add passkeys and 2FA since it can be reused.
*/
import { useState } from 'react';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { match } from 'ts-pattern';
import { import {
DocumentAuth, DocumentAuth,
@ -15,18 +6,17 @@ import {
type TRecipientActionAuthTypes, type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import type { FieldType } from '@documenso/prisma/client'; import type { FieldType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { DocumentActionAuth2FA } from './document-action-auth-2fa';
import { DocumentActionAuthAccount } from './document-action-auth-account';
import { DocumentActionAuthPasskey } from './document-action-auth-passkey';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuthDialogProps = { export type DocumentActionAuthDialogProps = {
@ -34,7 +24,6 @@ export type DocumentActionAuthDialogProps = {
documentAuthType: TRecipientActionAuthTypes; documentAuthType: TRecipientActionAuthTypes;
description?: string; description?: string;
actionTarget: FieldType | 'DOCUMENT'; actionTarget: FieldType | 'DOCUMENT';
isSubmitting?: boolean;
open: boolean; open: boolean;
onOpenChange: (value: boolean) => void; onOpenChange: (value: boolean) => void;
@ -44,96 +33,24 @@ export type DocumentActionAuthDialogProps = {
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void; onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
}; };
// const ZReauthFormSchema = z.object({
// password: ZCurrentPasswordSchema,
// });
// type TReauthFormSchema = z.infer<typeof ZReauthFormSchema>;
export const DocumentActionAuthDialog = ({ export const DocumentActionAuthDialog = ({
title, title,
description, description,
documentAuthType, documentAuthType,
// onReauthFormSubmit,
isSubmitting,
open, open,
onOpenChange, onOpenChange,
onReauthFormSubmit,
}: DocumentActionAuthDialogProps) => { }: DocumentActionAuthDialogProps) => {
const { recipient } = useRequiredDocumentAuthContext(); const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext();
// const form = useForm({
// resolver: zodResolver(ZReauthFormSchema),
// defaultValues: {
// password: '',
// },
// });
const [isSigningOut, setIsSigningOut] = useState(false);
const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting;
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
// const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
// const onFormSubmit = async (_values: TReauthFormSchema) => {
// const documentAuthValue: TRecipientActionAuth = match(documentAuthType)
// // Todo: Add passkey.
// // .with(DocumentAuthType.PASSKEY, (type) => ({
// // type,
// // value,
// // }))
// .otherwise((type) => ({
// type,
// }));
// try {
// await onReauthFormSubmit(documentAuthValue);
// onOpenChange(false);
// } catch (e) {
// const error = AppError.parseError(e);
// setFormErrorCode(error.code);
// // Suppress unauthorized errors since it's handled in this component.
// if (error.code === AppErrorCode.UNAUTHORIZED) {
// return;
// }
// throw error;
// }
// };
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
const encryptedEmail = await encryptSecondaryData({
data: email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
await signOut({
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
});
} catch {
setIsSigningOut(false);
// Todo: Alert.
}
};
const handleOnOpenChange = (value: boolean) => { const handleOnOpenChange = (value: boolean) => {
if (isLoading) { if (isCurrentlyAuthenticating) {
return; return;
} }
onOpenChange(value); onOpenChange(value);
}; };
// useEffect(() => {
// form.reset();
// setFormErrorCode(null);
// }, [open, form]);
return ( return (
<Dialog open={open} onOpenChange={handleOnOpenChange}> <Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent> <DialogContent>
@ -141,100 +58,32 @@ export const DocumentActionAuthDialog = ({
<DialogTitle>{title || 'Sign field'}</DialogTitle> <DialogTitle>{title || 'Sign field'}</DialogTitle>
<DialogDescription> <DialogDescription>
{description || `Reauthentication is required to sign the field`} {description || 'Reauthentication is required to sign this field'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{match(documentAuthType) {match({ documentAuthType, user })
.with(DocumentAuth.ACCOUNT, () => ( .with(
<fieldset disabled={isSigningOut} className="space-y-4"> { documentAuthType: DocumentAuth.ACCOUNT },
<Alert> { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
<AlertDescription> () => <DocumentActionAuthAccount onOpenChange={onOpenChange} />,
To sign this field, you need to be logged in as <strong>{recipient.email}</strong> )
</AlertDescription> .with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
</Alert> <DocumentActionAuthPasskey
open={open}
<DialogFooter> onOpenChange={onOpenChange}
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> onReauthFormSubmit={onReauthFormSubmit}
Cancel />
</Button>
<Button
type="submit"
onClick={async () => handleChangeAccount(recipient.email)}
loading={isSigningOut}
>
Login
</Button>
</DialogFooter>
</fieldset>
)) ))
.with(DocumentAuth.EXPLICIT_NONE, () => null) .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentActionAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()} .exhaustive()}
{/* <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isLoading}>
<FormItem>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input className="bg-background" value={recipient.email} disabled />
</FormControl>
</FormItem>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel required>Password</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
{match(formErrorCode)
.with(AppErrorCode.UNAUTHORIZED, () => (
<>
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please ensure the details are
correct
</AlertDescription>
</>
))
.otherwise(() => (
<>
<AlertTitle>Something went wrong</AlertTitle>
<AlertDescription>
We were unable to sign this field at this time. Please try again or
contact support.
</AlertDescription>
</>
))}
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isLoading}>
Sign field
</Button>
</DialogFooter>
</fieldset>
</form>
</Form> */}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -0,0 +1,252 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuthPasskeyProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZPasskeyAuthFormSchema = z.object({
passkeyId: z.string(),
});
type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
export const DocumentActionAuthPasskey = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentActionAuthPasskeyProps) => {
const {
recipient,
passkeyData,
preferredPasskeyId,
setPreferredPasskeyId,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
refetchPasskeys,
} = useRequiredDocumentAuthContext();
const form = useForm<TPasskeyAuthFormSchema>({
resolver: zodResolver(ZPasskeyAuthFormSchema),
defaultValues: {
passkeyId: preferredPasskeyId || '',
},
});
const { mutateAsync: createPasskeyAuthenticationOptions } =
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => {
try {
setPreferredPasskeyId(passkeyId);
setIsCurrentlyAuthenticating(true);
const { options, tokenReference } = await createPasskeyAuthenticationOptions({
preferredPasskeyId: passkeyId,
});
const authenticationResponse = await startAuthentication(options);
await onReauthFormSubmit({
type: DocumentAuth.PASSKEY,
authenticationResponse,
tokenReference,
});
setIsCurrentlyAuthenticating(false);
onOpenChange(false);
} catch (err) {
setIsCurrentlyAuthenticating(false);
if (err.name === 'NotAllowedError') {
return;
}
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
useEffect(() => {
form.reset({
passkeyId: preferredPasskeyId || '',
});
setFormErrorCode(null);
}, [open, form, preferredPasskeyId]);
if (!browserSupportsWebAuthn()) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
this {actionTarget.toLowerCase()}.
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</div>
);
}
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) {
return (
<div className="flex h-28 items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
if (passkeyData.isError) {
return (
<div className="h-28 space-y-4">
<Alert variant="destructive">
<AlertDescription>Something went wrong while loading your passkeys.</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void refetchPasskeys()}>
Retry
</Button>
</DialogFooter>
</div>
);
}
if (passkeyData.passkeys.length === 0) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup a passkey to mark this document as viewed.'
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<CreatePasskeyDialog
onSuccess={async () => refetchPasskeys()}
trigger={<Button>Setup</Button>}
/>
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<FormField
control={form.control}
name="passkeyId"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
data-testid="documentAccessSelectValue"
placeholder="Select passkey"
/>
</SelectTrigger>
<SelectContent position="popper">
{passkeyData.passkeys.map((passkey) => (
<SelectItem key={passkey.id} value={passkey.id}>
{passkey.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please try again or contact support
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -1,10 +1,10 @@
'use client'; 'use client';
import { createContext, useContext, useMemo, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import type { import type {
TDocumentAuthOptions, TDocumentAuthOptions,
TRecipientAccessAuthTypes, TRecipientAccessAuthTypes,
@ -13,11 +13,25 @@ import type {
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { DocumentAuth } from '@documenso/lib/types/document-auth'; import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { type Document, FieldType, type Recipient, type User } from '@documenso/prisma/client'; import {
type Document,
FieldType,
type Passkey,
type Recipient,
type User,
} from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog'; import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog';
import { DocumentActionAuthDialog } from './document-action-auth-dialog'; import { DocumentActionAuthDialog } from './document-action-auth-dialog';
type PasskeyData = {
passkeys: Omit<Passkey, 'credentialId' | 'credentialPublicKey'>[];
isInitialLoading: boolean;
isRefetching: boolean;
isError: boolean;
};
export type DocumentAuthContextValue = { export type DocumentAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>; executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
document: Document; document: Document;
@ -29,7 +43,13 @@ export type DocumentAuthContextValue = {
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
derivedRecipientActionAuth: TRecipientActionAuthTypes | null; derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
isAuthRedirectRequired: boolean; isAuthRedirectRequired: boolean;
isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void;
passkeyData: PasskeyData;
preferredPasskeyId: string | null;
setPreferredPasskeyId: (_value: string | null) => void;
user?: User | null; user?: User | null;
refetchPasskeys: () => Promise<void>;
}; };
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null); const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
@ -64,6 +84,9 @@ export const DocumentAuthProvider = ({
const [document, setDocument] = useState(initialDocument); const [document, setDocument] = useState(initialDocument);
const [recipient, setRecipient] = useState(initialRecipient); const [recipient, setRecipient] = useState(initialRecipient);
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(null);
const { const {
documentAuthOption, documentAuthOption,
recipientAuthOption, recipientAuthOption,
@ -78,6 +101,23 @@ export const DocumentAuthProvider = ({
[document, recipient], [document, recipient],
); );
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
{
perPage: MAXIMUM_PASSKEYS,
},
{
keepPreviousData: true,
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
},
);
const passkeyData: PasskeyData = {
passkeys: passkeyQuery.data?.data || [],
isInitialLoading: passkeyQuery.isInitialLoading,
isRefetching: passkeyQuery.isRefetching,
isError: passkeyQuery.isError,
};
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
useState<ExecuteActionAuthProcedureOptions | null>(null); useState<ExecuteActionAuthProcedureOptions | null>(null);
@ -101,7 +141,7 @@ export const DocumentAuthProvider = ({
.with(DocumentAuth.EXPLICIT_NONE, () => ({ .with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE, type: DocumentAuth.EXPLICIT_NONE,
})) }))
.with(null, () => null) .with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
.exhaustive(); .exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
@ -124,11 +164,27 @@ export const DocumentAuthProvider = ({
}); });
}; };
useEffect(() => {
const { passkeys } = passkeyData;
if (!preferredPasskeyId && passkeys.length > 0) {
setPreferredPasskeyId(passkeys[0].id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [passkeyData.passkeys]);
// Assume that a user must be logged in for any auth requirements.
const isAuthRedirectRequired = Boolean( const isAuthRedirectRequired = Boolean(
DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired && derivedRecipientActionAuth &&
!preCalculatedActionAuthOptions, derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
user?.email !== recipient.email,
); );
const refetchPasskeys = async () => {
await passkeyQuery.refetch();
};
return ( return (
<DocumentAuthContext.Provider <DocumentAuthContext.Provider
value={{ value={{
@ -143,6 +199,12 @@ export const DocumentAuthProvider = ({
derivedRecipientAccessAuth, derivedRecipientAccessAuth,
derivedRecipientActionAuth, derivedRecipientActionAuth,
isAuthRedirectRequired, isAuthRedirectRequired,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
passkeyData,
preferredPasskeyId,
setPreferredPasskeyId,
refetchPasskeys,
}} }}
> >
{children} {children}

View File

@ -42,10 +42,10 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
const { mutateAsync: completeDocumentWithToken } = const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const { const { handleSubmit, formState } = useForm();
handleSubmit,
formState: { isSubmitting }, // Keep the loading state going if successful since the redirect may take some time.
} = useForm(); const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted)); return sortFieldsByPosition(fields.filter((field) => !field.inserted));

View File

@ -7,9 +7,11 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = { export type SignDialogProps = {
@ -66,23 +68,39 @@ export const SignDialog = ({
{isComplete ? 'Complete' : 'Next field'} {isComplete ? 'Complete' : 'Next field'}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<div className="text-center"> <DialogTitle>
<div className="text-foreground text-xl font-semibold"> <div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'} {role === RecipientRole.VIEWER && 'Complete Viewing'}
{role === RecipientRole.SIGNER && 'Sign Document'} {role === RecipientRole.SIGNER && 'Complete Signing'}
{role === RecipientRole.APPROVER && 'Approve Document'} {role === RecipientRole.APPROVER && 'Complete Approval'}
</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
{role === RecipientRole.VIEWER &&
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.SIGNER &&
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.APPROVER &&
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
</div> </div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
You are about to complete viewing "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
You are about to complete signing "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
You are about to complete approving "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
</div> </div>
<SigningDisclosure className="mt-4" />
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button

View File

@ -18,6 +18,8 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
@ -200,6 +202,8 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
/> />
</div> </div>
<SigningDisclosure />
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button

View File

@ -0,0 +1,108 @@
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose">
<h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2>
<p>
Thank you for using Documenso to perform your electronic document signing. The purpose of
this disclosure is to inform you about the process, legality, and your rights regarding
the use of electronic signatures on our platform. By opting to use an electronic
signature, you are agreeing to the terms and conditions outlined below.
</p>
<h2>Acceptance and Consent</h2>
<p>
When you use our platform to affix your electronic signature to documents, you are
consenting to do so under the Electronic Signatures in Global and National Commerce Act
(E-Sign Act) and other applicable laws. This action indicates your agreement to use
electronic means to sign documents and receive notifications.
</p>
<h2>Legality of Electronic Signatures</h2>
<p>
An electronic signature provided by you on our platform, achieved through clicking through
to a document and entering your name, or any other electronic signing method we provide,
is legally binding. It carries the same weight and enforceability as a manual signature
written with ink on paper.
</p>
<h2>System Requirements</h2>
<p>To use our electronic signature service, you must have access to:</p>
<ul>
<li>A stable internet connection</li>
<li>An email account</li>
<li>A device capable of accessing, opening, and reading documents</li>
<li>A means to print or download documents for your records</li>
</ul>
<h2>Electronic Delivery of Documents</h2>
<p>
All documents related to the electronic signing process will be provided to you
electronically through our platform or via email. It is your responsibility to ensure that
your email address is current and that you can receive and open our emails.
</p>
<h2>Consent to Electronic Transactions</h2>
<p>
By using the electronic signature feature, you are consenting to conduct transactions and
receive disclosures electronically. You acknowledge that your electronic signature on
documents is binding and that you accept the terms outlined in the documents you are
signing.
</p>
<h2>Withdrawing Consent</h2>
<p>
You have the right to withdraw your consent to use electronic signatures at any time
before completing the signing process. To withdraw your consent, please contact the sender
of the document. In failing to contact the sender you may reach out to{' '}
<a href="mailto:support@documenso.com">support@documenso.com</a> for assistance. Be aware
that withdrawing consent may delay or halt the completion of the related transaction or
service.
</p>
<h2>Updating Your Information</h2>
<p>
It is crucial to keep your contact information, especially your email address, up to date
with us. Please notify us immediately of any changes to ensure that you continue to
receive all necessary communications.
</p>
<h2>Retention of Documents</h2>
<p>
After signing a document electronically, you will be provided the opportunity to view,
download, and print the document for your records. It is highly recommended that you
retain a copy of all electronically signed documents for your personal records. We will
also retain a copy of the signed document for our records however we may not be able to
provide you with a copy of the signed document after a certain period of time.
</p>
<h2>Acknowledgment</h2>
<p>
By proceeding to use the electronic signature service provided by Documenso, you affirm
that you have read and understood this disclosure. You agree to all terms and conditions
related to the use of electronic signatures and electronic transactions as outlined
herein.
</p>
<h2>Contact Information</h2>
<p>
For any questions regarding this disclosure, electronic signatures, or any related
process, please contact us at:{' '}
<a href="mailto:support@documenso.com">support@documenso.com</a>
</p>
</article>
<div className="mt-8">
<Button asChild>
<Link href="/documents">Back to Documents</Link>
</Button>
</div>
</div>
);
}

View File

@ -2,6 +2,7 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google'; import { Caveat, Inter } from 'next/font/google';
import { AxiomWebVitals } from 'next-axiom';
import { PublicEnvScript } from 'next-runtime-env'; import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
@ -71,6 +72,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<PublicEnvScript /> <PublicEnvScript />
</head> </head>
<AxiomWebVitals />
<Suspense> <Suspense>
<PostHogPageview /> <PostHogPageview />
</Suspense> </Suspense>

View File

@ -2,7 +2,7 @@ import React from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';

View File

@ -1,19 +1,22 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog'; import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Mail, PlusCircle, Trash } from 'lucide-react'; import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { TeamMemberRole } from '@documenso/prisma/client'; import { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -39,6 +42,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type InviteTeamMembersDialogProps = { export type InviteTeamMembersDialogProps = {
@ -51,18 +55,45 @@ const ZInviteTeamMembersFormSchema = z
.object({ .object({
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
}) })
.refine( // Display exactly which rows are duplicates.
(schema) => { .superRefine((items, ctx) => {
const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase()); const uniqueEmails = new Map<string, number>();
return new Set(emails).size === emails.length; for (const [index, invitation] of items.invitations.entries()) {
}, const email = invitation.email.toLowerCase();
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Members must have unique emails', path: ['members__root'] }, const firstFoundIndex = uniqueEmails.get(email);
);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', firstFoundIndex, 'email'],
});
}
});
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>; type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportTeamMemberSchema = z.array(
z.object({
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
}),
);
export const InviteTeamMembersDialog = ({ export const InviteTeamMembersDialog = ({
currentUserTeamRole, currentUserTeamRole,
teamId, teamId,
@ -70,6 +101,8 @@ export const InviteTeamMembersDialog = ({
...props ...props
}: InviteTeamMembersDialogProps) => { }: InviteTeamMembersDialogProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
const { toast } = useToast(); const { toast } = useToast();
@ -130,9 +163,75 @@ export const InviteTeamMembersDialog = ({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
form.reset(); form.reset();
setInvitationType('INDIVIDUAL');
} }
}, [open, form]); }, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const csvFile = e.target.files[0];
Papa.parse(csvFile, {
skipEmptyLines: true,
comments: 'Work email,Job title',
complete: (results: ParseResult<string[]>) => {
const members = results.data.map((row) => {
const [email, role] = row;
return {
email: email.trim(),
role: role.trim().toUpperCase(),
};
});
// Remove the first row if it contains the headers.
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
members.shift();
}
try {
const importedInvitations = ZImportTeamMemberSchema.parse(members);
form.setValue('invitations', importedInvitations);
form.clearErrors('invitations');
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err.message);
toast({
variant: 'destructive',
title: 'Something went wrong',
description: 'Please check the CSV file and make sure it is according to our format',
});
}
},
});
};
const downloadTemplate = () => {
const data = [
{ email: 'admin@documenso.com', role: 'Admin' },
{ email: 'manager@documenso.com', role: 'Manager' },
{ email: 'member@documenso.com', role: 'Member' },
];
const csvContent =
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const blob = new Blob([csvContent], {
type: 'text/csv',
});
downloadFile({
filename: 'documenso-team-member-invites-template.csv',
data: blob,
});
};
return ( return (
<Dialog <Dialog
{...props} {...props}
@ -152,92 +251,144 @@ export const InviteTeamMembersDialog = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Tabs
<form onSubmit={form.handleSubmit(onFormSubmit)}> defaultValue="INDIVIDUAL"
<fieldset value={invitationType}
className="flex h-full flex-col space-y-4" // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
disabled={form.formState.isSubmitting} onValueChange={(value) => setInvitationType(value as TabTypes)}
> >
{teamMemberInvites.map((teamMemberInvite, index) => ( <TabsList className="w-full">
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}> <TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<FormField <MailIcon size={20} className="mr-2" />
control={form.control} Invite Members
name={`invitations.${index}.email`} </TabsTrigger>
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email address</FormLabel>}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <TabsTrigger value="BULK" className="hover:text-foreground w-full">
control={form.control} <UsersIcon size={20} className="mr-2" /> Bulk Import
name={`invitations.${index}.role`} </TabsTrigger>
render={({ field }) => ( </TabsList>
<FormItem className="w-full">
{index === 0 && <FormLabel required>Role</FormLabel>}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper"> <TabsContent value="INDIVIDUAL">
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => ( <Form {...form}>
<SelectItem key={role} value={role}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
{TEAM_MEMBER_ROLE_MAP[role] ?? role} <fieldset
</SelectItem> className="flex h-full flex-col space-y-4"
))} disabled={form.formState.isSubmitting}
</SelectContent> >
</Select> <div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
</FormControl> {teamMemberInvites.map((teamMemberInvite, index) => (
<FormMessage /> <div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
</FormItem> <FormField
)} control={form.control}
/> name={`invitations.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email address</FormLabel>}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button <FormField
control={form.control}
name={`invitations.${index}.role`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Role</FormLabel>}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={teamMemberInvites.length === 1}
onClick={() => removeTeamMemberInvite(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
</div>
<Button
type="button" type="button"
className={cn( size="sm"
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50', variant="outline"
index === 0 ? 'mt-8' : 'mt-0', className="w-fit"
)} onClick={() => onAddTeamMemberInvite()}
disabled={teamMemberInvites.length === 1}
onClick={() => removeTeamMemberInvite(index)}
> >
<Trash className="h-5 w-5" /> <PlusCircle className="mr-2 h-4 w-4" />
</button> Add more
</div> </Button>
))}
<Button <DialogFooter>
type="button" <Button type="button" variant="secondary" onClick={() => setOpen(false)}>
size="sm" Cancel
variant="outline" </Button>
className="w-fit"
onClick={() => onAddTeamMemberInvite()} <Button type="submit" loading={form.formState.isSubmitting}>
> {!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
<PlusCircle className="mr-2 h-4 w-4" /> Invite
Add more </Button>
</Button> </DialogFooter>
</fieldset>
</form>
</Form>
</TabsContent>
<TabsContent value="BULK">
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
<p className="mt-1 text-sm">Click here to upload</p>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
/>
</CardContent>
</Card>
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}> <Button type="button" variant="secondary" onClick={downloadTemplate}>
Cancel <Download className="mr-2 h-4 w-4" />
</Button> Template
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
Invite
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset> </div>
</form> </TabsContent>
</Form> </Tabs>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -1,4 +1,4 @@
import { SVGAttributes } from 'react'; import type { SVGAttributes } from 'react';
export type LogoProps = SVGAttributes<SVGSVGElement>; export type LogoProps = SVGAttributes<SVGSVGElement>;

View File

@ -1,9 +1,9 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { Globe, Lock } from 'lucide-react'; import { Globe, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
type TemplateTypeIcon = { type TemplateTypeIcon = {

View File

@ -41,8 +41,13 @@ export const ZEnable2FAForm = z.object({
export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>; export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
export const EnableAuthenticatorAppDialog = () => { export type EnableAuthenticatorAppDialogProps = {
onSuccess?: () => void;
};
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -79,6 +84,7 @@ export const EnableAuthenticatorAppDialog = () => {
const data = await enable2FA({ code: token }); const data = await enable2FA({ code: token });
setRecoveryCodes(data.recoveryCodes); setRecoveryCodes(data.recoveryCodes);
onSuccess?.();
toast({ toast({
title: 'Two-factor authentication enabled', title: 'Two-factor authentication enabled',
@ -89,7 +95,7 @@ export const EnableAuthenticatorAppDialog = () => {
toast({ toast({
title: 'Unable to setup two-factor authentication', title: 'Unable to setup two-factor authentication',
description: description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.', 'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.',
variant: 'destructive', variant: 'destructive',
}); });
} }

View File

@ -47,12 +47,9 @@ export const ViewRecoveryCodesDialog = () => {
data: recoveryCodes, data: recoveryCodes,
mutate, mutate,
isLoading, isLoading,
isError,
error, error,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
// error?.data?.code
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({ const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: { defaultValues: {
token: '', token: '',

View File

@ -55,11 +55,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
}); });
const isSubmitting = form.formState.isSubmitting; const isSubmitting = form.formState.isSubmitting;
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
try { try {

View File

@ -124,7 +124,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
}; };
const onSignInWithPasskey = async () => { const onSignInWithPasskey = async () => {
if (!browserSupportsWebAuthn) { if (!browserSupportsWebAuthn()) {
toast({ toast({
title: 'Not supported', title: 'Not supported',
description: 'Passkeys are not supported on this browser', description: 'Passkeys are not supported on this browser',

View File

@ -0,0 +1,29 @@
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { cn } from '@documenso/ui/lib/utils';
export type SigningDisclosureProps = HTMLAttributes<HTMLParagraphElement>;
export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => {
return (
<p className={cn('text-muted-foreground text-xs', className)} {...props}>
By proceeding with your electronic signature, you acknowledge and consent that it will be used
to sign the given document and holds the same legal validity as a handwritten signature. By
completing the electronic signing process, you affirm your understanding and acceptance of
these conditions.
<span className="mt-2 block">
Read the full{' '}
<Link
className="text-documenso-700 underline"
href="/articles/signature-disclosure"
target="_blank"
>
signature disclosure
</Link>
.
</span>
</p>
);
};

View File

@ -1,4 +1,4 @@
import { SVGAttributes } from 'react'; import type { SVGAttributes } from 'react';
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>; export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;

View File

@ -3,7 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { ThemeProviderProps } from 'next-themes/dist/types'; import type { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

39
package-lock.json generated
View File

@ -50,6 +50,7 @@
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "14.0.3", "next": "14.0.3",
"next-auth": "4.24.5", "next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-contentlayer": "^0.3.4", "next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",
@ -112,8 +113,10 @@
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "14.0.3", "next": "14.0.3",
"next-auth": "4.24.5", "next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"papaparse": "^5.4.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3", "posthog-js": "^1.75.3",
"posthog-node": "^3.1.1", "posthog-node": "^3.1.1",
@ -137,6 +140,7 @@
"@types/formidable": "^2.0.6", "@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/node": "20.1.0", "@types/node": "20.1.0",
"@types/papaparse": "^5.3.14",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
@ -8088,6 +8092,15 @@
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="
}, },
"node_modules/@types/papaparse": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz",
"integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse5": { "node_modules/@types/parse5": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz",
@ -16677,6 +16690,22 @@
} }
} }
}, },
"node_modules/next-axiom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/next-axiom/-/next-axiom-1.1.1.tgz",
"integrity": "sha512-0r/TJ+/zetD+uDc7B+2E7WpC86hEtQ1U+DuWYrP/JNmUz+ZdPFbrZgzOSqaZ6TwYbXP56VVlPfYwq1YsKHTHYQ==",
"dependencies": {
"remeda": "^1.29.0",
"whatwg-fetch": "^3.6.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"next": ">=13.4",
"react": ">=18.0.0"
}
},
"node_modules/next-contentlayer": { "node_modules/next-contentlayer": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/next-contentlayer/-/next-contentlayer-0.3.4.tgz", "resolved": "https://registry.npmjs.org/next-contentlayer/-/next-contentlayer-0.3.4.tgz",
@ -17245,6 +17274,11 @@
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
}, },
"node_modules/papaparse": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -22945,6 +22979,11 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
}, },
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@ -0,0 +1,54 @@
import { expect, test } from '@playwright/test';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
await apiSignin({
page,
email: user.email,
});
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
await apiSignin({
page,
email: recipient.email,
});
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
await apiSignin({
page,
email: recipient.email,
});
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});

View File

@ -71,7 +71,6 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
await apiSignin({ await apiSignin({
page, page,
email: recipientWithAccount.email, email: recipientWithAccount.email,
redirectPath: '/',
}); });
// Check that the one logged in is granted access. // Check that the one logged in is granted access.

View File

@ -14,7 +14,7 @@ import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/user
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel', timeout: 60000 });
test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => { test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
@ -191,7 +191,7 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field', 'Reauthentication is required to sign this field',
); );
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
} }
@ -260,7 +260,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field', 'Reauthentication is required to sign this field',
); );
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
} }
@ -371,7 +371,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field', 'Reauthentication is required to sign this field',
); );
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
} }

View File

@ -1,18 +1,29 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import path from 'node:path'; import path from 'node:path';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email'; import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './fixtures/authentication'; import { apiSignin } from '../fixtures/authentication';
test(`[PR-718]: should be able to create a document`, async ({ page }) => { // Can't use the function in server-only/document due to it indirectly using
await page.goto('/signin'); // require imports.
const getDocumentByToken = async (token: string) => {
const documentTitle = `example-${Date.now()}.pdf`; return await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
});
};
test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
await apiSignin({ await apiSignin({
@ -20,7 +31,7 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
email: user.email, email: user.email,
}); });
// Upload document // Upload document.
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'), page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => { page.locator('input[type=file]').evaluate((e) => {
@ -30,10 +41,23 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
}), }),
]); ]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
// Wait to be redirected to the edit page // Wait to be redirected to the edit page.
await page.waitForURL(/\/documents\/\d+/); await page.waitForURL(/\/documents\/\d+/);
});
test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
// Set general settings // Set general settings
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@ -79,34 +103,23 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
// Assert document was created // Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await unseedUser(user.id);
}); });
test('should be able to create a document with multiple recipients', async ({ page }) => { test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({
await page.goto('/signin'); page,
}) => {
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser(); const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({ await apiSignin({
page, page,
email: user.email, email: user.email,
redirectPath: `/documents/${document.id}/edit`,
}); });
// Upload document const documentTitle = `example-${Date.now()}.pdf`;
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title // Set title
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@ -175,34 +188,21 @@ test('should be able to create a document with multiple recipients', async ({ pa
// Assert document was created // Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await unseedUser(user.id);
}); });
test('should be able to create, send and sign a document', async ({ page }) => { test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser(); const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({ await apiSignin({
page, page,
email: user.email, email: user.email,
redirectPath: `/documents/${document.id}/edit`,
}); });
// Upload document const documentTitle = `example-${Date.now()}.pdf`;
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title // Set title
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@ -246,49 +246,36 @@ test('should be able to create, send and sign a document', async ({ page }) => {
await page.waitForURL(`/sign/${token}`); await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed // Check if document has been viewed
const { status } = await getDocumentByToken({ token }); const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING); expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible(); await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`); await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('You have signed')).toBeVisible(); await expect(page.getByText('You have signed')).toBeVisible();
// Check if document has been signed // Check if document has been signed
const { status: completedStatus } = await getDocumentByToken({ token }); const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED); expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await unseedUser(user.id);
}); });
test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
page, page,
}) => { }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser(); const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({ await apiSignin({
page, page,
email: user.email, email: user.email,
redirectPath: `/documents/${document.id}/edit`,
}); });
// Upload document const documentTitle = `example-${Date.now()}.pdf`;
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title & advanced redirect // Set title & advanced redirect
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@ -331,16 +318,18 @@ test('should be able to create, send with redirect url, sign a document and redi
await page.waitForURL(`/sign/${token}`); await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed // Check if document has been viewed
const { status } = await getDocumentByToken({ token }); const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING); expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible(); await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL('https://documenso.com'); await page.waitForURL('https://documenso.com');
// Check if document has been signed // Check if document has been signed
const { status: completedStatus } = await getDocumentByToken({ token }); const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED); expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await unseedUser(user.id);
}); });

View File

@ -0,0 +1,172 @@
import { expect, test } from '@playwright/test';
import {
seedCompletedDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'serial' });
const seedDeleteDocumentsTestRequirements = async () => {
const [sender, recipientA, recipientB] = await Promise.all([seedUser(), seedUser(), seedUser()]);
const [draftDocument, pendingDocument, completedDocument] = await Promise.all([
seedDraftDocument(sender, [recipientA, recipientB], {
createDocumentOptions: { title: 'Document 1 - Draft' },
}),
seedPendingDocument(sender, [recipientA, recipientB], {
createDocumentOptions: { title: 'Document 1 - Pending' },
}),
seedCompletedDocument(sender, [recipientA, recipientB], {
createDocumentOptions: { title: 'Document 1 - Completed' },
}),
]);
return {
sender,
recipients: [recipientA, recipientB],
draftDocument,
pendingDocument,
completedDocument,
};
};
test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
await apiSignout({ page });
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.email,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
await apiSignout({ page });
}
});
test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' })
.getByRole('button')
.nth(1)
.click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await apiSignout({ page });
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.email,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await page.getByRole('link', { name: 'Document 1 - Completed' }).click();
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
await apiSignout({ page });
}
});
test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({
page,
}) => {
const { sender, pendingDocument } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// open actions menu
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// signout
await apiSignout({ page });
for (const recipient of pendingDocument.Recipient) {
await apiSignin({
page,
email: recipient.email,
});
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
await page.waitForURL('/documents');
await apiSignout({ page });
}
});
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
page,
}) => {
const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Draft' })
.getByRole('cell', { name: 'Edit' })
.getByRole('button')
.click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
});

View File

@ -13,38 +13,11 @@ type LoginOptions = {
redirectPath?: string; redirectPath?: string;
}; };
export const manualLogin = async ({
page,
email = 'example@documenso.com',
password = 'password',
redirectPath,
}: LoginOptions) => {
await page.goto(`${WEBAPP_BASE_URL}/signin`);
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByLabel('Password', { exact: true }).press('Enter');
if (redirectPath) {
await page.waitForURL(`${WEBAPP_BASE_URL}/documents`);
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
}
};
export const manualSignout = async ({ page }: LoginOptions) => {
await page.waitForTimeout(1000);
await page.getByTestId('menu-switcher').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
};
export const apiSignin = async ({ export const apiSignin = async ({
page, page,
email = 'example@documenso.com', email = 'example@documenso.com',
password = 'password', password = 'password',
redirectPath = '/', redirectPath = '/documents',
}: LoginOptions) => { }: LoginOptions) => {
const { request } = page.context(); const { request } = page.context();
@ -59,9 +32,7 @@ export const apiSignin = async ({
}, },
}); });
if (redirectPath) { await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
}
}; };
export const apiSignout = async ({ page }: { page: Page }) => { export const apiSignout = async ({ page }: { page: Page }) => {

View File

@ -1,159 +0,0 @@
import { expect, test } from '@playwright/test';
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
import { manualLogin, manualSignout } from './fixtures/authentication';
test.describe.configure({ mode: 'serial' });
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
const [sender, ...recipients] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(sender.email);
await page.getByLabel('Password', { exact: true }).fill(sender.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
await manualSignout({ page });
for (const recipient of recipients) {
await page.waitForURL('/signin');
await manualLogin({ page, email: recipient.email, password: recipient.password });
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
await manualSignout({ page });
}
});
test('[PR-711]: deleting a completed document should not remove it from recipients', async ({
page,
}) => {
const [sender, ...recipients] = TEST_USERS;
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(sender.email);
await page.getByLabel('Password', { exact: true }).fill(sender.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' })
.getByRole('button')
.nth(1)
.click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await manualSignout({ page });
for (const recipient of recipients) {
await page.waitForURL('/signin');
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(recipient.email);
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await page.goto(`/sign/completed-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
await page.goto('/documents');
await manualSignout({ page });
}
});
test('[PR-711]: deleting a pending document should remove it from recipients', async ({ page }) => {
const [sender, ...recipients] = TEST_USERS;
for (const recipient of recipients) {
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText('Waiting for others to sign').nth(0)).toBeVisible();
}
await page.goto('/signin');
await manualLogin({ page, email: sender.email, password: sender.password });
await page.waitForURL('/documents');
// open actions menu
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// signout
await manualSignout({ page });
for (const recipient of recipients) {
await page.waitForURL('/signin');
await manualLogin({ page, email: recipient.email, password: recipient.password });
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
await page.waitForURL('/documents');
await manualSignout({ page });
}
});
test('[PR-711]: deleting a draft document should remove it without additional prompting', async ({
page,
}) => {
const [sender] = TEST_USERS;
await manualLogin({ page, email: sender.email, password: sender.password });
await page.waitForURL('/documents');
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Draft' })
.getByRole('cell', { name: 'Edit' })
.getByRole('button')
.click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
});

View File

@ -1,54 +0,0 @@
import { expect, test } from '@playwright/test';
import { TEST_USERS } from '@documenso/prisma/seed/pr-713-add-document-search-to-command-menu';
test('[PR-713]: should see sent documents', async ({ page }) => {
const [user] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill('sent');
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
});
test('[PR-713]: should see received documents', async ({ page }) => {
const [user] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill('received');
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
});
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
const [user, recipient] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
});

View File

@ -11,6 +11,11 @@ test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: create team', async ({ page }) => { test('[TEAMS]: create team', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
'Test skipped because billing is enabled.',
);
await apiSignin({ await apiSignin({
page, page,
email: user.email, email: user.email,
@ -26,9 +31,6 @@ test('[TEAMS]: create team', async ({ page }) => {
await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' }); await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' });
const isCheckoutRequired = page.url().includes('pending');
test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.');
// Goto new team settings page. // Goto new team settings page.
await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click(); await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();

View File

@ -108,7 +108,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible(); await expect(page.getByText('Template deleted').first()).toBeVisible();
await page.waitForTimeout(1000); await page.reload();
} }
await unseedTeam(team.url); await unseedTeam(team.url);

View File

@ -2,6 +2,7 @@ import { type Page, expect, test } from '@playwright/test';
import { import {
extractUserVerificationToken, extractUserVerificationToken,
seedTestEmail,
seedUser, seedUser,
unseedUser, unseedUser,
unseedUserByEmail, unseedUserByEmail,
@ -9,9 +10,9 @@ import {
test.use({ storageState: { cookies: [], origins: [] } }); test.use({ storageState: { cookies: [], origins: [] } });
test('user can sign up with email and password', async ({ page }: { page: Page }) => { test('[USER] can sign up with email and password', async ({ page }: { page: Page }) => {
const username = 'Test User'; const username = 'Test User';
const email = `test-user-${Date.now()}@auth-flow.documenso.com`; const email = seedTestEmail();
const password = 'Password123#'; const password = 'Password123#';
await page.goto('/signup'); await page.goto('/signup');
@ -30,7 +31,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
} }
await page.getByRole('button', { name: 'Next', exact: true }).click(); await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByLabel('Public profile username').fill('username-123'); await page.getByLabel('Public profile username').fill(Date.now().toString());
await page.getByRole('button', { name: 'Complete', exact: true }).click(); await page.getByRole('button', { name: 'Complete', exact: true }).click();
@ -50,7 +51,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
await unseedUserByEmail(email); await unseedUserByEmail(email);
}); });
test('user can login with user and password', async ({ page }: { page: Page }) => { test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => {
const user = await seedUser(); const user = await seedUser();
await page.goto('/signin'); await page.goto('/signin');

View File

@ -4,19 +4,16 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { manualLogin } from './fixtures/authentication'; import { apiSignin } from '../fixtures/authentication';
test('delete user', async ({ page }) => { test('[USER] delete account', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
await manualLogin({ await apiSignin({ page, email: user.email, redirectPath: '/settings' });
page,
email: user.email,
redirectPath: '/settings',
});
await page.getByRole('button', { name: 'Delete Account' }).click(); await page.getByRole('button', { name: 'Delete Account' }).click();
await page.getByLabel('Confirm Email').fill(user.email); await page.getByLabel('Confirm Email').fill(user.email);
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled(); await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
await page.getByRole('button', { name: 'Confirm Deletion' }).click(); await page.getByRole('button', { name: 'Confirm Deletion' }).click();

View File

@ -3,16 +3,12 @@ import { expect, test } from '@playwright/test';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { manualLogin } from './fixtures/authentication'; import { apiSignin } from '../fixtures/authentication';
test('update user name', async ({ page }) => { test('[USER] update full name', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
await manualLogin({ await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' });
page,
email: user.email,
redirectPath: '/settings/profile',
});
await page.getByLabel('Full Name').fill('John Doe'); await page.getByLabel('Full Name').fill('John Doe');

View File

@ -17,12 +17,11 @@ export default defineConfig({
testDir: './e2e', testDir: './e2e',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: true,
workers: '50%',
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 1,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@ -4,26 +4,21 @@ import { DocumentAuth } from '../types/document-auth';
type DocumentAuthTypeData = { type DocumentAuthTypeData = {
key: TDocumentAuth; key: TDocumentAuth;
value: string; value: string;
/**
* Whether this authentication event will require the user to halt and
* redirect.
*
* Defaults to false.
*/
isAuthRedirectRequired?: boolean;
}; };
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = { export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
[DocumentAuth.ACCOUNT]: { [DocumentAuth.ACCOUNT]: {
key: DocumentAuth.ACCOUNT, key: DocumentAuth.ACCOUNT,
value: 'Require account', value: 'Require account',
isAuthRedirectRequired: true,
}, },
// [DocumentAuthType.PASSKEY]: { [DocumentAuth.PASSKEY]: {
// key: DocumentAuthType.PASSKEY, key: DocumentAuth.PASSKEY,
// value: 'Require passkey', value: 'Require passkey',
// }, },
[DocumentAuth.TWO_FACTOR_AUTH]: {
key: DocumentAuth.TWO_FACTOR_AUTH,
value: 'Require 2FA',
},
[DocumentAuth.EXPLICIT_NONE]: { [DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE, key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)', value: 'None (Overrides global settings)',

View File

@ -22,7 +22,7 @@ import { sendConfirmationToken } from '../server-only/user/send-confirmation-tok
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn'; import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn'; import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { getAuthenticatorRegistrationOptions } from '../utils/authenticator'; import { getAuthenticatorOptions } from '../utils/authenticator';
import { ErrorCode } from './error-codes'; import { ErrorCode } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = { export const NEXT_AUTH_OPTIONS: AuthOptions = {
@ -196,7 +196,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const user = passkey.User; const user = passkey.User;
const { rpId, origin } = getAuthenticatorRegistrationOptions(); const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({ const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential, response: requestBodyCrediential,

View File

@ -1,4 +1,4 @@
import { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
@ -9,9 +9,5 @@ type IsTwoFactorAuthenticationEnabledOptions = {
export const isTwoFactorAuthenticationEnabled = ({ export const isTwoFactorAuthenticationEnabled = ({
user, user,
}: IsTwoFactorAuthenticationEnabledOptions) => { }: IsTwoFactorAuthenticationEnabledOptions) => {
return ( return user.twoFactorEnabled && typeof DOCUMENSO_ENCRYPTION_KEY === 'string';
user.twoFactorEnabled &&
user.identityProvider === 'DOCUMENSO' &&
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
);
}; };

View File

@ -0,0 +1,76 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { Passkey } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyAuthenticationOptions = {
userId: number;
/**
* The ID of the passkey to request authentication for.
*
* If not set, we allow the browser client to handle choosing.
*/
preferredPasskeyId?: string;
};
export const createPasskeyAuthenticationOptions = async ({
userId,
preferredPasskeyId,
}: CreatePasskeyAuthenticationOptions) => {
const { rpId, timeout } = getAuthenticatorOptions();
let preferredPasskey: Pick<Passkey, 'credentialId' | 'transports'> | null = null;
if (preferredPasskeyId) {
preferredPasskey = await prisma.passkey.findFirst({
where: {
userId,
id: preferredPasskeyId,
},
select: {
credentialId: true,
transports: true,
},
});
if (!preferredPasskey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found');
}
}
const options = await generateAuthenticationOptions({
rpID: rpId,
userVerification: 'preferred',
timeout,
allowCredentials: preferredPasskey
? [
{
id: preferredPasskey.credentialId,
type: 'public-key',
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
transports: preferredPasskey.transports as AuthenticatorTransportFuture[],
},
]
: undefined,
});
const { secondaryId } = await prisma.verificationToken.create({
data: {
userId,
token: options.challenge,
expires: DateTime.now().plus({ minutes: 2 }).toJSDate(),
identifier: 'PASSKEY_CHALLENGE',
},
});
return {
tokenReference: secondaryId,
options,
};
};

View File

@ -5,7 +5,7 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { PASSKEY_TIMEOUT } from '../../constants/auth'; import { PASSKEY_TIMEOUT } from '../../constants/auth';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyRegistrationOptions = { type CreatePasskeyRegistrationOptions = {
userId: number; userId: number;
@ -27,7 +27,7 @@ export const createPasskeyRegistrationOptions = async ({
const { passkeys } = user; const { passkeys } = user;
const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions(); const { rpName, rpId: rpID } = getAuthenticatorOptions();
const options = await generateRegistrationOptions({ const options = await generateRegistrationOptions({
rpName, rpName,

View File

@ -3,14 +3,14 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeySigninOptions = { type CreatePasskeySigninOptions = {
sessionId: string; sessionId: string;
}; };
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => { export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
const { rpId, timeout } = getAuthenticatorRegistrationOptions(); const { rpId, timeout } = getAuthenticatorOptions();
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID: rpId, rpID: rpId,

View File

@ -7,7 +7,7 @@ import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { MAXIMUM_PASSKEYS } from '../../constants/auth'; import { MAXIMUM_PASSKEYS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyOptions = { type CreatePasskeyOptions = {
userId: number; userId: number;
@ -64,7 +64,7 @@ export const createPasskey = async ({
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired'); throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
} }
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions(); const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
const verification = await verifyRegistrationResponse({ const verification = await verifyRegistrationResponse({
response: verificationResponse, response: verificationResponse,

View File

@ -11,6 +11,7 @@ export interface FindPasskeysOptions {
orderBy?: { orderBy?: {
column: keyof Passkey; column: keyof Passkey;
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
nulls?: Prisma.NullsOrder;
}; };
} }
@ -21,8 +22,9 @@ export const findPasskeys = async ({
perPage = 10, perPage = 10,
orderBy, orderBy,
}: FindPasskeysOptions) => { }: FindPasskeysOptions) => {
const orderByColumn = orderBy?.column ?? 'name'; const orderByColumn = orderBy?.column ?? 'lastUsedAt';
const orderByDirection = orderBy?.direction ?? 'desc'; const orderByDirection = orderBy?.direction ?? 'desc';
const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last';
const whereClause: Prisma.PasskeyWhereInput = { const whereClause: Prisma.PasskeyWhereInput = {
userId, userId,
@ -41,7 +43,10 @@ export const findPasskeys = async ({
skip: Math.max(page - 1, 0) * perPage, skip: Math.max(page - 1, 0) * perPage,
take: perPage, take: perPage,
orderBy: { orderBy: {
[orderByColumn]: orderByDirection, [orderByColumn]: {
sort: orderByDirection,
nulls: orderByNulls,
},
}, },
select: { select: {
id: true, id: true,

View File

@ -1,10 +1,15 @@
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, Recipient } from '@documenso/prisma/client'; import type { Document, Recipient } from '@documenso/prisma/client';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth'; import { DocumentAuth } from '../../types/document-auth';
import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn';
import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = { type IsRecipientAuthorizedOptions = {
@ -63,17 +68,20 @@ export const isRecipientAuthorized = async ({
return true; return true;
} }
// Create auth options when none are passed for account.
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
authOptions = {
type: DocumentAuth.ACCOUNT,
};
}
// Authentication required does not match provided method. // Authentication required does not match provided method.
if (authOptions && authOptions.type !== authMethod) { if (!authOptions || authOptions.type !== authMethod || !userId) {
return false; return false;
} }
return await match(authMethod) return await match(authOptions)
.with(DocumentAuth.ACCOUNT, async () => { .with({ type: DocumentAuth.ACCOUNT }, async () => {
if (userId === undefined) {
return false;
}
const recipientUser = await getUserByEmail(recipient.email); const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) { if (!recipientUser) {
@ -82,5 +90,124 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId; return recipientUser.id === userId;
}) })
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
return await isPasskeyAuthValid({
userId,
authenticationResponse,
tokenReference,
});
})
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
// Should not be possible.
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
}
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
});
})
.exhaustive(); .exhaustive();
}; };
type VerifyPasskeyOptions = {
/**
* The ID of the user who initiated the request.
*/
userId: number;
/**
* The secondary ID of the verification token.
*/
tokenReference: string;
/**
* The response from the passkey authenticator.
*/
authenticationResponse: TAuthenticationResponseJSONSchema;
};
/**
* Whether the provided passkey authenticator response is valid and the user is
* authenticated.
*/
const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise<boolean> => {
return verifyPasskey(options)
.then(() => true)
.catch(() => false);
};
/**
* Verifies whether the provided passkey authenticator is valid and the user is
* authenticated.
*
* Will throw an error if the user should not be authenticated.
*/
const verifyPasskey = async ({
userId,
tokenReference,
authenticationResponse,
}: VerifyPasskeyOptions): Promise<void> => {
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
userId,
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
}
const verificationToken = await prisma.verificationToken
.delete({
where: {
userId,
secondaryId: tokenReference,
},
})
.catch(() => null);
if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
}
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
}
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: verificationToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null); // May want to log this for insights.
if (verification?.verified !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
};

View File

@ -1,9 +1,16 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZAuthenticationResponseJSONSchema } from './webauthn';
/** /**
* All the available types of document authentication options for both access and action. * All the available types of document authentication options for both access and action.
*/ */
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']); export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'EXPLICIT_NONE',
]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({ const ZDocumentAuthAccountSchema = z.object({
@ -14,12 +21,25 @@ const ZDocumentAuthExplicitNoneSchema = z.object({
type: z.literal(DocumentAuth.EXPLICIT_NONE), type: z.literal(DocumentAuth.EXPLICIT_NONE),
}); });
const ZDocumentAuthPasskeySchema = z.object({
type: z.literal(DocumentAuth.PASSKEY),
authenticationResponse: ZAuthenticationResponseJSONSchema,
tokenReference: z.string().min(1),
});
const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
token: z.string().min(4).max(10),
});
/** /**
* All the document auth methods for both accessing and actioning. * All the document auth methods for both accessing and actioning.
*/ */
export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema, ZDocumentAuthAccountSchema,
ZDocumentAuthExplicitNoneSchema, ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
]); ]);
/** /**
@ -35,8 +55,16 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
* *
* Must keep these two in sync. * Must keep these two in sync.
*/ */
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here. export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
]);
export const ZDocumentActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
]);
/** /**
* The recipient access auth methods. * The recipient access auth methods.
@ -54,11 +82,15 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
* Must keep these two in sync. * Must keep these two in sync.
*/ */
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema, // Todo: Add passkeys here. ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExplicitNoneSchema, ZDocumentAuthExplicitNoneSchema,
]); ]);
export const ZRecipientActionAuthTypesSchema = z.enum([ export const ZRecipientActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT, DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXPLICIT_NONE, DocumentAuth.EXPLICIT_NONE,
]); ]);

View File

@ -4,7 +4,7 @@ import { PASSKEY_TIMEOUT } from '../constants/auth';
/** /**
* Extracts common fields to identify the RP (relying party) * Extracts common fields to identify the RP (relying party)
*/ */
export const getAuthenticatorRegistrationOptions = () => { export const getAuthenticatorOptions = () => {
const webAppBaseUrl = new URL(WEBAPP_BASE_URL); const webAppBaseUrl = new URL(WEBAPP_BASE_URL);
const rpId = webAppBaseUrl.hostname; const rpId = webAppBaseUrl.hostname;

View File

@ -0,0 +1,18 @@
/*
Warnings:
- A unique constraint covering the columns `[secondaryId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail.
- The required column `secondaryId` was added to the `VerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT;
-- Set all null secondaryId fields to a uuid
UPDATE "VerificationToken" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
-- Restrict the VerificationToken to required
ALTER TABLE "VerificationToken" ALTER COLUMN "secondaryId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId");

View File

@ -126,13 +126,14 @@ model AnonymousVerificationToken {
} }
model VerificationToken { model VerificationToken {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
identifier String secondaryId String @unique @default(cuid())
token String @unique identifier String
expires DateTime token String @unique
createdAt DateTime @default(now()) expires DateTime
userId Int createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
enum WebhookTriggerEvents { enum WebhookTriggerEvents {

View File

@ -213,7 +213,14 @@ export const seedPendingDocument = async (
}); });
} }
return document; return prisma.document.findFirstOrThrow({
where: {
id: document.id,
},
include: {
Recipient: true,
},
});
}; };
export const seedPendingDocumentNoFields = async ({ export const seedPendingDocumentNoFields = async ({

View File

@ -1,223 +0,0 @@
import type { User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '../client';
const PULL_REQUEST_NUMBER = 711;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USERS = [
{
name: 'Sender 1',
email: `sender1@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'Sender 2',
email: `sender2@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'Sender 3',
email: `sender3@${EMAIL_DOMAIN}`,
password: 'Password123',
},
] as const;
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
export const seedDatabase = async () => {
const users = await Promise.all(
TEST_USERS.map(async (u) =>
prisma.user.create({
data: {
name: u.name,
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
url: u.email,
},
}),
),
);
const [user1, user2, user3] = users;
await createDraftDocument(user1, [user2, user3]);
await createPendingDocument(user1, [user2, user3]);
await createCompletedDocument(user1, [user2, user3]);
};
const createDraftDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Draft`,
status: DocumentStatus.DRAFT,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `draft-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createPendingDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Pending`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `pending-token-${index}`,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createCompletedDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`,
status: DocumentStatus.COMPLETED,
documentDataId: documentData.id,
completedAt: new Date(),
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `completed-token-${index}`,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};

View File

@ -1,168 +0,0 @@
import type { User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '../client';
//
// https://github.com/documenso/documenso/pull/713
//
const PULL_REQUEST_NUMBER = 713;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USERS = [
{
name: 'User 1',
email: `user1@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'User 2',
email: `user2@${EMAIL_DOMAIN}`,
password: 'Password123',
},
] as const;
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
export const seedDatabase = async () => {
const users = await Promise.all(
TEST_USERS.map(async (u) =>
prisma.user.create({
data: {
name: u.name,
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
url: u.email,
},
}),
),
);
const [user1, user2] = users;
await createSentDocument(user1, [user2]);
await createReceivedDocument(user2, [user1]);
};
const createSentDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document - Sent`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `sent-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createReceivedDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document - Received`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `received-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};

View File

@ -1,8 +1,11 @@
import { customAlphabet } from 'nanoid';
import { prisma } from '..'; import { prisma } from '..';
import { TeamMemberInviteStatus, TeamMemberRole } from '../client'; import { TeamMemberInviteStatus, TeamMemberRole } from '../client';
import { seedUser } from './users'; import { seedUser } from './users';
const EMAIL_DOMAIN = `test.documenso.com`; const EMAIL_DOMAIN = `test.documenso.com`;
const nanoid = customAlphabet('1234567890abcdef', 10);
type SeedTeamOptions = { type SeedTeamOptions = {
createTeamMembers?: number; createTeamMembers?: number;
@ -13,7 +16,7 @@ export const seedTeam = async ({
createTeamMembers = 0, createTeamMembers = 0,
createTeamEmail, createTeamEmail,
}: SeedTeamOptions = {}) => { }: SeedTeamOptions = {}) => {
const teamUrl = `team-${Date.now()}`; const teamUrl = `team-${nanoid()}`;
const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail; const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail;
const teamOwner = await seedUser({ const teamOwner = await seedUser({

View File

@ -1,3 +1,5 @@
import { customAlphabet } from 'nanoid';
import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..'; import { prisma } from '..';
@ -11,12 +13,22 @@ type SeedUserOptions = {
verified?: boolean; verified?: boolean;
}; };
const nanoid = customAlphabet('1234567890abcdef', 10);
export const seedUser = async ({ export const seedUser = async ({
name = `user-${Date.now()}`, name,
email = `user-${Date.now()}@test.documenso.com`, email,
password = 'password', password = 'password',
verified = true, verified = true,
}: SeedUserOptions = {}) => { }: SeedUserOptions = {}) => {
if (!name) {
name = nanoid();
}
if (!email) {
email = `${nanoid()}@test.documenso.com`;
}
return await prisma.user.create({ return await prisma.user.create({
data: { data: {
name, name,

View File

@ -29,6 +29,8 @@ export const adminRouter = router({
try { try {
return await findDocuments({ term, page, perPage }); return await findDocuments({ term, page, perPage });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to retrieve the documents. Please try again.', message: 'We were unable to retrieve the documents. Please try again.',
@ -44,6 +46,8 @@ export const adminRouter = router({
try { try {
return await updateUser({ id, name, email, roles }); return await updateUser({ id, name, email, roles });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to retrieve the specified account. Please try again.', message: 'We were unable to retrieve the specified account. Please try again.',
@ -59,6 +63,8 @@ export const adminRouter = router({
try { try {
return await updateRecipient({ id, name, email }); return await updateRecipient({ id, name, email });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to update the recipient provided.', message: 'We were unable to update the recipient provided.',
@ -79,6 +85,8 @@ export const adminRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
}); });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to update the site setting provided.', message: 'We were unable to update the site setting provided.',
@ -95,6 +103,7 @@ export const adminRouter = router({
return await sealDocument({ documentId: id, isResealing: true }); return await sealDocument({ documentId: id, isResealing: true });
} catch (err) { } catch (err) {
console.log('resealDocument error', err); console.log('resealDocument error', err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to reseal the document provided.', message: 'We were unable to reseal the document provided.',

View File

@ -16,7 +16,9 @@ export const apiTokenRouter = router({
getTokens: authenticatedProcedure.query(async ({ ctx }) => { getTokens: authenticatedProcedure.query(async ({ ctx }) => {
try { try {
return await getUserTokens({ userId: ctx.user.id }); return await getUserTokens({ userId: ctx.user.id });
} catch (e) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to find your API tokens. Please try again.', message: 'We were unable to find your API tokens. Please try again.',
@ -34,7 +36,9 @@ export const apiTokenRouter = router({
id, id,
userId: ctx.user.id, userId: ctx.user.id,
}); });
} catch (e) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to find this API token. Please try again.', message: 'We were unable to find this API token. Please try again.',
@ -54,7 +58,9 @@ export const apiTokenRouter = router({
tokenName, tokenName,
expiresIn: expirationDate, expiresIn: expirationDate,
}); });
} catch (e) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to create an API token. Please try again.', message: 'We were unable to create an API token. Please try again.',
@ -73,7 +79,9 @@ export const apiTokenRouter = router({
teamId, teamId,
userId: ctx.user.id, userId: ctx.user.id,
}); });
} catch (e) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to delete this API Token. Please try again.', message: 'We were unable to delete this API Token. Please try again.',

View File

@ -7,6 +7,7 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options'; import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options';
import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
@ -19,6 +20,7 @@ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
ZCreatePasskeyAuthenticationOptionsMutationSchema,
ZCreatePasskeyMutationSchema, ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema, ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema, ZFindPasskeysQuerySchema,
@ -115,6 +117,25 @@ export const authRouter = router({
} }
}), }),
createPasskeyAuthenticationOptions: authenticatedProcedure
.input(ZCreatePasskeyAuthenticationOptionsMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
return await createPasskeyAuthenticationOptions({
userId: ctx.user.id,
preferredPasskeyId: input?.preferredPasskeyId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to create the authentication options for the passkey. Please try again later.',
});
}
}),
createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => { createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => {
try { try {
return await createPasskeyRegistrationOptions({ return await createPasskeyRegistrationOptions({

View File

@ -40,6 +40,12 @@ export const ZCreatePasskeyMutationSchema = z.object({
verificationResponse: ZRegistrationResponseJSONSchema, verificationResponse: ZRegistrationResponseJSONSchema,
}); });
export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z
.object({
preferredPasskeyId: z.string().optional(),
})
.optional();
export const ZDeletePasskeyMutationSchema = z.object({ export const ZDeletePasskeyMutationSchema = z.object({
passkeyId: z.string().trim().min(1), passkeyId: z.string().trim().min(1),
}); });

View File

@ -115,6 +115,8 @@ export const documentRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err);
if (err instanceof TRPCError) { if (err instanceof TRPCError) {
throw err; throw err;
} }
@ -222,13 +224,19 @@ export const documentRouter = router({
const userId = ctx.user.id; const userId = ctx.user.id;
return await updateTitle({ try {
title, return await updateTitle({
userId, title,
teamId, userId,
documentId, teamId,
requestMetadata: extractNextApiRequestMetadata(ctx.req), documentId,
}); requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw err;
}
}), }),
setPasswordForDocument: authenticatedProcedure setPasswordForDocument: authenticatedProcedure
@ -347,7 +355,9 @@ export const documentRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
}); });
return documents; return documents;
} catch (error) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We are unable to search for documents. Please try again later.', message: 'We are unable to search for documents. Please try again later.',

View File

@ -52,20 +52,26 @@ export const fieldRouter = router({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { templateId, fields } = input; const { templateId, fields } = input;
await setFieldsForTemplate({ try {
userId: ctx.user.id, await setFieldsForTemplate({
templateId, userId: ctx.user.id,
fields: fields.map((field) => ({ templateId,
id: field.nativeId, fields: fields.map((field) => ({
signerEmail: field.signerEmail, id: field.nativeId,
type: field.type, signerEmail: field.signerEmail,
pageNumber: field.pageNumber, type: field.type,
pageX: field.pageX, pageNumber: field.pageNumber,
pageY: field.pageY, pageX: field.pageX,
pageWidth: field.pageWidth, pageY: field.pageY,
pageHeight: field.pageHeight, pageWidth: field.pageWidth,
})), pageHeight: field.pageHeight,
}); })),
});
} catch (err) {
console.error(err);
throw err;
}
}), }),
signFieldWithToken: procedure signFieldWithToken: procedure

View File

@ -37,6 +37,8 @@ export const profileRouter = router({
...input, ...input,
}); });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to find user security audit logs. Please try again.', message: 'We were unable to find user security audit logs. Please try again.',
@ -50,6 +52,8 @@ export const profileRouter = router({
return await getUserById({ id }); return await getUserById({ id });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to retrieve the specified account. Please try again.', message: 'We were unable to retrieve the specified account. Please try again.',
@ -108,6 +112,8 @@ export const profileRouter = router({
return { success: true, url: user.url }; return { success: true, url: user.url };
} catch (err) { } catch (err) {
console.error(err);
const error = AppError.parseError(err); const error = AppError.parseError(err);
if (error.code !== AppErrorCode.UNKNOWN_ERROR) { if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
@ -135,6 +141,8 @@ export const profileRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err);
let message = let message =
'We were unable to update your profile. Please review the information you provided and try again.'; 'We were unable to update your profile. Please review the information you provided and try again.';
@ -171,6 +179,8 @@ export const profileRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err);
let message = 'We were unable to reset your password. Please try again.'; let message = 'We were unable to reset your password. Please try again.';
if (err instanceof Error) { if (err instanceof Error) {
@ -192,6 +202,8 @@ export const profileRouter = router({
return await sendConfirmationToken({ email }); return await sendConfirmationToken({ email });
} catch (err) { } catch (err) {
console.error(err);
let message = 'We were unable to send a confirmation email. Please try again.'; let message = 'We were unable to send a confirmation email. Please try again.';
if (err instanceof Error) { if (err instanceof Error) {
@ -211,6 +223,8 @@ export const profileRouter = router({
id: ctx.user.id, id: ctx.user.id,
}); });
} catch (err) { } catch (err) {
console.error(err);
let message = 'We were unable to delete your account. Please try again.'; let message = 'We were unable to delete your account. Please try again.';
if (err instanceof Error) { if (err instanceof Error) {

View File

@ -29,151 +29,157 @@ export const singleplayerRouter = router({
createSinglePlayerDocument: procedure createSinglePlayerDocument: procedure
.input(ZCreateSinglePlayerDocumentMutationSchema) .input(ZCreateSinglePlayerDocumentMutationSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { signer, fields, documentData, documentName } = input; try {
const { signer, fields, documentData, documentName } = input;
const document = await getFile({ const document = await getFile({
data: documentData.data, data: documentData.data,
type: documentData.type, type: documentData.type,
});
const doc = await PDFDocument.load(document);
const createdAt = new Date();
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
const typedSignature = !isBase64 ? signer.signature : null;
// Update the document with the fields inserted.
for (const field of fields) {
const isSignatureField = field.type === FieldType.SIGNATURE;
await insertFieldInPDF(doc, {
...mapField(field, signer),
Signature: isSignatureField
? {
created: createdAt,
signatureImageAsBase64,
typedSignature,
// Dummy data.
id: -1,
recipientId: -1,
fieldId: -1,
}
: null,
// Dummy data.
id: -1,
secondaryId: '-1',
documentId: -1,
templateId: null,
recipientId: -1,
}); });
}
const unsignedPdfBytes = await doc.save(); const doc = await PDFDocument.load(document);
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) }); const createdAt = new Date();
const { token } = await prisma.$transaction( const isBase64 = signer.signature.startsWith('data:image/png;base64,');
async (tx) => { const signatureImageAsBase64 = isBase64 ? signer.signature : null;
const token = alphaid(); const typedSignature = !isBase64 ? signer.signature : null;
// Fetch service user who will be the owner of the document. // Update the document with the fields inserted.
const serviceUser = await tx.user.findFirstOrThrow({ for (const field of fields) {
where: { const isSignatureField = field.type === FieldType.SIGNATURE;
email: SERVICE_USER_EMAIL,
}, await insertFieldInPDF(doc, {
...mapField(field, signer),
Signature: isSignatureField
? {
created: createdAt,
signatureImageAsBase64,
typedSignature,
// Dummy data.
id: -1,
recipientId: -1,
fieldId: -1,
}
: null,
// Dummy data.
id: -1,
secondaryId: '-1',
documentId: -1,
templateId: null,
recipientId: -1,
}); });
}
const { id: documentDataId } = await putFile({ const unsignedPdfBytes = await doc.save();
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
});
// Create document. const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
const document = await tx.document.create({
data: {
title: documentName,
status: DocumentStatus.COMPLETED,
documentDataId,
userId: serviceUser.id,
createdAt,
},
});
// Create recipient. const { token } = await prisma.$transaction(
const recipient = await tx.recipient.create({ async (tx) => {
data: { const token = alphaid();
documentId: document.id,
name: signer.name,
email: signer.email,
token,
signedAt: createdAt,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
});
// Create fields and signatures. // Fetch service user who will be the owner of the document.
await Promise.all( const serviceUser = await tx.user.findFirstOrThrow({
fields.map(async (field) => { where: {
const insertedField = await tx.field.create({ email: SERVICE_USER_EMAIL,
data: { },
documentId: document.id, });
recipientId: recipient.id,
...mapField(field, signer),
},
});
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { const { id: documentDataId } = await putFile({
await tx.signature.create({ name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
});
// Create document.
const document = await tx.document.create({
data: {
title: documentName,
status: DocumentStatus.COMPLETED,
documentDataId,
userId: serviceUser.id,
createdAt,
},
});
// Create recipient.
const recipient = await tx.recipient.create({
data: {
documentId: document.id,
name: signer.name,
email: signer.email,
token,
signedAt: createdAt,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
});
// Create fields and signatures.
await Promise.all(
fields.map(async (field) => {
const insertedField = await tx.field.create({
data: { data: {
fieldId: insertedField.id, documentId: document.id,
signatureImageAsBase64,
typedSignature,
recipientId: recipient.id, recipientId: recipient.id,
...mapField(field, signer),
}, },
}); });
}
}),
);
return { document, token }; if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
}, await tx.signature.create({
{ data: {
maxWait: 5000, fieldId: insertedField.id,
timeout: 30000, signatureImageAsBase64,
}, typedSignature,
); recipientId: recipient.id,
},
});
}
}),
);
const template = createElement(DocumentSelfSignedEmailTemplate, { return { document, token };
documentName: documentName, },
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000', {
}); maxWait: 5000,
timeout: 30000,
},
);
const [html, text] = await Promise.all([ const template = createElement(DocumentSelfSignedEmailTemplate, {
renderAsync(template), documentName: documentName,
renderAsync(template, { plainText: true }), assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
]); });
// Send email to signer. const [html, text] = await Promise.all([
await mailer.sendMail({ renderAsync(template),
to: { renderAsync(template, { plainText: true }),
address: signer.email, ]);
name: signer.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document signed',
html,
text,
attachments: [{ content: signedPdfBuffer, filename: documentName }],
});
return token; // Send email to signer.
await mailer.sendMail({
to: {
address: signer.email,
name: signer.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document signed',
html,
text,
attachments: [{ content: signedPdfBuffer, filename: documentName }],
});
return token;
} catch (err) {
console.error(err);
throw err;
}
}), }),
}); });

View File

@ -56,6 +56,8 @@ export const templateRouter = router({
recipients: input.recipients, recipients: input.recipients,
}); });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.', message: 'We were unable to create this document. Please try again later.',

View File

@ -21,6 +21,8 @@ export const webhookRouter = router({
try { try {
return await getWebhooksByUserId(ctx.user.id); return await getWebhooksByUserId(ctx.user.id);
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhooks. Please try again later.', message: 'We were unable to fetch your webhooks. Please try again later.',
@ -36,6 +38,8 @@ export const webhookRouter = router({
try { try {
return await getWebhooksByTeamId(teamId, ctx.user.id); return await getWebhooksByTeamId(teamId, ctx.user.id);
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhooks. Please try again later.', message: 'We were unable to fetch your webhooks. Please try again later.',
@ -55,6 +59,8 @@ export const webhookRouter = router({
teamId, teamId,
}); });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhook. Please try again later.', message: 'We were unable to fetch your webhook. Please try again later.',
@ -77,6 +83,8 @@ export const webhookRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
}); });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.', message: 'We were unable to create this webhook. Please try again later.',
@ -96,6 +104,8 @@ export const webhookRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
}); });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.', message: 'We were unable to create this webhook. Please try again later.',
@ -116,6 +126,8 @@ export const webhookRouter = router({
teamId, teamId,
}); });
} catch (err) { } catch (err) {
console.error(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.', message: 'We were unable to create this webhook. Please try again later.',

View File

@ -219,6 +219,14 @@ export const AddSettingsFormPartial = ({
<li> <li>
<strong>Require account</strong> - The recipient must be signed in <strong>Require account</strong> - The recipient must be signed in
</li> </li>
<li>
<strong>Require passkey</strong> - The recipient must have an account
and passkey configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and
2FA enabled via their settings
</li>
<li> <li>
<strong>None</strong> - No authentication required <strong>None</strong> - No authentication required
</li> </li>

View File

@ -287,6 +287,14 @@ export const AddSignersFormPartial = ({
<strong>Require account</strong> - The recipient must be <strong>Require account</strong> - The recipient must be
signed in signed in
</li> </li>
<li>
<strong>Require passkey</strong> - The recipient must have
an account and passkey configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an
account and 2FA enabled via their settings
</li>
<li> <li>
<strong>None</strong> - No authentication required <strong>None</strong> - No authentication required
</li> </li>