mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
Merge branch 'main' into chore/status-widget-new
This commit is contained in:
10
.env.example
10
.env.example
@ -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"
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { SVGAttributes } from 'react';
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
||||||
|
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: '',
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
29
apps/web/src/components/general/signing-disclosure.tsx
Normal file
29
apps/web/src/components/general/signing-disclosure.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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'>;
|
||||||
|
|
||||||
|
|||||||
@ -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
39
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
54
packages/app-tests/e2e/command-menu/document-search.spec.ts
Normal file
54
packages/app-tests/e2e/command-menu/document-search.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
172
packages/app-tests/e2e/documents/delete-documents.spec.ts
Normal file
172
packages/app-tests/e2e/documents/delete-documents.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -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 }) => {
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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');
|
||||||
@ -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();
|
||||||
|
|
||||||
@ -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');
|
||||||
|
|
||||||
@ -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. */
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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 ({
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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({
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user