Compare commits

..

2 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 6f532208cb fix: use custom status widget 2024-03-21 14:35:29 +00:00
Ephraim Atta-Duncan fa25f6d24e chore: add status widget 2024-03-21 13:40:48 +00:00
162 changed files with 1921 additions and 8096 deletions
-4
View File
@@ -55,9 +55,6 @@ NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
# OPTIONAL: Defines the force path style to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
# This will change it from using virtual hosts <bucket>.domain.com/<path> to fully qualified paths domain.com/<bucket>/<path>
NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE="false"
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION="unknown"
# REQUIRED: Defines the bucket to use for the S3 storage transport.
@@ -107,7 +104,6 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.
+1
View File
@@ -19,6 +19,7 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@openstatus/react": "^0.0.3",
"contentlayer": "^0.3.4",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
+3 -68
View File
@@ -1,79 +1,14 @@
'use client';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header';
import { LayoutHeader } from '~/components/(marketing)/layout-header';
export type MarketingLayoutProps = {
children: React.ReactNode;
};
export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden':
pathname && !['/singleplayer', '/pricing'].includes(pathname),
})}
>
<div
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
{showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
<div className="absolute inset-0 -z-[1]">
<Image
src={launchWeekTwoImage}
className="h-full w-full object-cover"
alt="Launch Week 2"
/>
</div>
<div className="text-background text-center text-sm text-white">
Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
<a
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=marketing-announcement-bar`}
className="bg-background text-foreground rounded-md px-2.5 py-1 text-xs font-medium duration-300"
>
Claim Now
</a>
</div>
</div>
</div>
)}
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
<div className="relative flex min-h-[100vh] max-w-[100vw] flex-col overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
<LayoutHeader />
<div className="relative max-w-screen-xl flex-1 px-4 sm:mx-auto lg:px-8">{children}</div>
@@ -14,7 +14,7 @@ export const MonthlyCompletedDocumentsChart = ({
className,
data,
}: MonthlyCompletedDocumentsChartProps) => {
const formattedData = [...data].reverse().map(({ month, count }) => {
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
@@ -161,7 +161,6 @@ export const SinglePlayerClient = () => {
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
role: 'SIGNER',
authOptions: null,
};
const onFileDrop = async (file: File) => {
@@ -185,84 +184,85 @@ export const SinglePlayerClient = () => {
};
return (
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<div className="overflow-x-auto overflow-y-hidden">
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
free account
</Link>{' '}
or view our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
early adopter plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer
documentData={{
id: '',
data: uploadedFile.fileBase64,
initialData: uploadedFile.fileBase64,
type: DocumentDataType.BYTES_64,
}}
/>
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
free account
</Link>{' '}
or view our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
early adopter plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="top-24 lg:h-[calc(100vh-7rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer
documentData={{
id: '',
data: uploadedFile.fileBase64,
initialData: uploadedFile.fileBase64,
type: DocumentDataType.BYTES_64,
}}
/>
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="top-24 lg:h-[calc(100vh-7rem)]"
onSubmit={(e) => e.preventDefault()}
>
{/* Add fields to PDF page. */}
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
>
{/* Add fields to PDF page. */}
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
{/* Enter user details and signature. */}
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
fields={fields}
onSubmit={onFieldsSubmit}
isDocumentPdfLoaded={true}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
</fieldset>
{/* Enter user details and signature. */}
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
</Stepper>
</DocumentFlowFormContainer>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
</div>
</div>
@@ -1,5 +1,3 @@
'use client';
import type { HTMLAttributes } from 'react';
import Image from 'next/image';
@@ -13,6 +11,8 @@ import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
import { StatusWidget } from './status-widget';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
const SOCIAL_LINKS = [
@@ -62,6 +62,10 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</Link>
))}
</div>
<div className="mt-6">
<StatusWidget />
</div>
</div>
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
@@ -0,0 +1,65 @@
'use client';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Header } from '~/components/(marketing)/header';
export function LayoutHeader() {
const [scrollY, setScrollY] = useState(0);
const { getFlag } = useFeatureFlags();
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
{showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
<div className="absolute inset-0 -z-[1]">
<Image
src={launchWeekTwoImage}
className="h-full w-full object-cover"
alt="Launch Week 2"
/>
</div>
<div className="text-background text-center text-sm text-white">
Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
<a
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=marketing-announcement-bar`}
className="bg-background text-foreground rounded-md px-2.5 py-1 text-xs font-medium duration-300"
>
Claim Now
</a>
</div>
</div>
</div>
)}
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
);
}
@@ -23,7 +23,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
return (
<div className={cn('', className)} {...props}>
<div className="bg-background sticky top-32 flex items-center justify-end gap-x-6 shadow-[-1px_-5px_2px_6px_hsl(var(--background))] md:top-[7.5rem] lg:static lg:justify-center">
<div className="flex items-center justify-center gap-x-6">
<AnimatePresence>
<motion.button
key="MONTHLY"
@@ -40,7 +40,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
{period === 'MONTHLY' && (
<motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/>
)}
</motion.button>
@@ -63,7 +63,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
{period === 'YEARLY' && (
<motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/>
)}
</motion.button>
@@ -0,0 +1,73 @@
import type { Status } from '@openstatus/react';
import { getStatus } from '@openstatus/react';
import { cn } from '@documenso/ui/lib/utils';
const getStatusLevel = (level: Status) => {
return {
operational: {
label: 'Operational',
color: 'bg-green-500',
color2: 'bg-green-400',
},
degraded_performance: {
label: 'Degraded Performance',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
partial_outage: {
label: 'Partial Outage',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
major_outage: {
label: 'Major Outage',
color: 'bg-red-500',
color2: 'bg-red-400',
},
unknown: {
label: 'Unknown',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
incident: {
label: 'Incident',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
under_maintenance: {
label: 'Under Maintenance',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
}[level];
};
export async function StatusWidget() {
const { status } = await getStatus('documenso');
const level = getStatusLevel(status);
return (
<a
className="border-border inline-flex max-w-fit items-center justify-between gap-2 space-x-2 rounded-md border border-gray-200 px-3 py-1 text-sm"
href="https://status.documenso.com"
target="_blank"
rel="noreferrer"
>
<div>
<p className="text-sm">{level.label}</p>
</div>
<span className="relative ml-auto flex h-1.5 w-1.5">
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
level.color2,
)}
/>
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', level.color)} />
</span>
</a>
);
}
-4
View File
@@ -22,10 +22,7 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
@@ -54,7 +51,6 @@
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@simplewebauthn/types": "^9.0.1",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
@@ -14,7 +14,6 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { AdminActions } from './admin-actions';
import { RecipientItem } from './recipient-item';
import { SuperDeleteDocumentDialog } from './super-delete-document-dialog';
type AdminDocumentDetailsPageProps = {
params: {
@@ -82,10 +81,6 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
))}
</Accordion>
</div>
<hr className="my-4" />
{document && <SuperDeleteDocumentDialog document={document} />}
</div>
);
}
@@ -1,130 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Document } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type SuperDeleteDocumentDialogProps = {
document: Document;
};
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
const { toast } = useToast();
const router = useRouter();
const [reason, setReason] = useState('');
const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } =
trpc.admin.deleteDocument.useMutation();
const handleDeleteDocument = async () => {
try {
if (!reason) {
return;
}
await deleteDocument({ id: document.id, reason });
toast({
title: 'Document deleted',
description: 'The Document has been deleted successfully.',
duration: 5000,
});
router.push('/admin/documents');
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your document. Please try again later.',
});
}
}
};
return (
<div>
<div>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
variant="neutral"
>
<div>
<AlertTitle>Delete Document</AlertTitle>
<AlertDescription className="mr-2">
Delete the document. This action is irreversible so proceed with caution.
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Document</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Document</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>To confirm, please enter the reason</DialogDescription>
<Input
className="mt-2"
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button
onClick={handleDeleteDocument}
loading={isDeletingDocument}
variant="destructive"
disabled={!reason}
>
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
</div>
);
};
@@ -58,7 +58,6 @@ export const UsersDataTable = ({
perPage,
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchString]);
const onPaginationChange = (page: number, perPage: number) => {
@@ -1,25 +1,29 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
type DocumentData,
type DocumentMeta,
DocumentStatus,
type Field,
type Recipient,
type User,
} from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSettingsFormPartial } from '@documenso/ui/primitives/document-flow/add-settings';
import type { TAddSettingsFormSchema } from '@documenso/ui/primitives/document-flow/add-settings.types';
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@@ -30,19 +34,27 @@ import { useOptionalCurrentTeam } from '~/providers/team';
export type EditDocumentFormProps = {
className?: string;
initialDocument: DocumentWithDetails;
user: User;
document: DocumentWithData;
recipients: Recipient[];
documentMeta: DocumentMeta | null;
fields: Field[];
documentData: DocumentData;
documentRootPath: string;
isDocumentEnterprise: boolean;
};
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject'];
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
export const EditDocumentForm = ({
className,
initialDocument,
document,
recipients,
fields,
documentMeta,
user: _user,
documentData,
documentRootPath,
isDocumentEnterprise,
}: EditDocumentFormProps) => {
const { toast } = useToast();
@@ -50,83 +62,17 @@ export const EditDocumentForm = ({
const searchParams = useSearchParams();
const team = useOptionalCurrentTeam();
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const utils = trpc.useUtils();
const { data: document, refetch: refetchDocument } =
trpc.document.getDocumentWithDetailsById.useQuery(
{
id: initialDocument.id,
teamId: team?.id,
},
{
initialData: initialDocument,
...SKIP_QUERY_BATCH_META,
},
);
const { Recipient: recipients, Field: fields } = document;
const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newFields) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), Field: newFields }),
);
},
});
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newRecipients) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), Recipient: newRecipients }),
);
},
});
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
},
});
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the document.',
title: {
title: 'Add Title',
description: 'Add the title to the document.',
stepIndex: 1,
},
signers: {
@@ -150,7 +96,8 @@ export const EditDocumentForm = ({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
let initialStep: EditDocumentStep = 'settings';
let initialStep: EditDocumentStep =
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
if (
searchParamStep &&
@@ -163,26 +110,15 @@ export const EditDocumentForm = ({
return initialStep;
});
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl } = data.meta;
await setSettingsForDocument({
// Custom invocation server action
await addTitle({
documentId: document.id,
teamId: team?.id,
data: {
title: data.title,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: {
timezone,
dateFormat,
redirectUrl,
},
title: data.title,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('signers');
@@ -191,7 +127,7 @@ export const EditDocumentForm = ({
toast({
title: 'Error',
description: 'An error occurred while updating the document settings.',
description: 'An error occurred while updating title.',
variant: 'destructive',
});
}
@@ -199,19 +135,14 @@ export const EditDocumentForm = ({
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
// Custom invocation server action
await addSigners({
documentId: document.id,
teamId: team?.id,
signers: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth || null,
})),
signers: data.signers,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('fields');
} catch (err) {
console.error(err);
@@ -226,14 +157,13 @@ export const EditDocumentForm = ({
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try {
// Custom invocation server action
await addFields({
documentId: document.id,
fields: data.fields,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('subject');
} catch (err) {
console.error(err);
@@ -247,7 +177,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.meta;
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
try {
await sendDocument({
@@ -256,6 +186,9 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
dateFormat,
timezone,
redirectUrl,
},
});
@@ -286,15 +219,6 @@ export const EditDocumentForm = ({
const currentDocumentFlow = documentFlow[step];
/**
* Refresh the data in the background when steps change.
*/
useEffect(() => {
void refetchDocument();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
@@ -303,12 +227,11 @@ export const EditDocumentForm = ({
>
<CardContent className="p-2">
<LazyPDFViewer
key={document.documentData.id}
documentData={document.documentData}
key={documentData.id}
documentData={documentData}
document={document}
password={document.documentMeta?.password}
password={documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
@@ -322,35 +245,30 @@ export const EditDocumentForm = ({
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
>
<AddSettingsFormPartial
<AddTitleFormPartial
key={recipients.length}
documentFlow={documentFlow.settings}
documentFlow={documentFlow.title}
document={document}
recipients={recipients}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit}
onSubmit={onAddTitleFormSubmit}
/>
<AddSignersFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
document={document}
recipients={recipients}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddSubjectFormPartial
key={recipients.length}
documentFlow={documentFlow.subject}
@@ -358,7 +276,6 @@ export const EditDocumentForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSubjectFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
</Stepper>
</DocumentFlowFormContainer>
@@ -3,10 +3,11 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
@@ -36,18 +37,13 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const { user } = await getRequiredServerComponentSession();
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
const document = await getDocumentWithDetailsById({
const document = await getDocumentById({
id: documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!document) {
if (!document || !document.documentData) {
redirect(documentRootPath);
}
@@ -55,7 +51,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
redirect(`${documentRootPath}/${documentId}`);
}
const { documentMeta, Recipient: recipients } = document;
const { documentData, documentMeta } = document;
if (documentMeta?.password) {
const key = DOCUMENSO_ENCRYPTION_KEY;
@@ -74,6 +70,18 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
documentMeta.password = securePassword;
}
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
@@ -101,9 +109,13 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<EditDocumentForm
className="mt-8"
initialDocument={document}
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
isDocumentEnterprise={isDocumentEnterprise}
/>
</div>
);
@@ -15,14 +15,15 @@ export default function SettingsSecurityActivityPage() {
<SettingsHeader
title="Security activity"
subtitle="View all recent security activity related to your account."
hideDivider={true}
>
<ActivityPageBackButton />
<div>
<ActivityPageBackButton />
</div>
</SettingsHeader>
<div className="mt-4">
<UserSecurityActivityDataTable />
</div>
<hr className="my-4" />
<UserSecurityActivityDataTable />
</div>
);
}
@@ -1,15 +1,14 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
import { PasswordForm } from '~/components/forms/password';
export const metadata: Metadata = {
@@ -19,8 +18,6 @@ export const metadata: Metadata = {
export default async function SecuritySettingsPage() {
const { user } = await getRequiredServerComponentSession();
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
return (
<div>
<SettingsHeader
@@ -28,70 +25,57 @@ export default async function SecuritySettingsPage() {
subtitle="Here you can manage your password and security settings."
/>
{user.identityProvider === 'DOCUMENSO' && (
<>
{user.identityProvider === 'DOCUMENSO' ? (
<div>
<PasswordForm user={user} />
<hr className="border-border/50 mt-6" />
</>
)}
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Two factor authentication</AlertTitle>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Two factor authentication</AlertTitle>
<AlertDescription className="mr-4">
Add an authenticator to serve as a secondary authentication method{' '}
{user.identityProvider === 'DOCUMENSO'
? 'when signing in, or when signing documents.'
: 'for signing documents.'}
</AlertDescription>
<AlertDescription className="mr-4">
Create one-time passwords that serve as a secondary authentication method for
confirming your identity when requested during the sign-in process.
</AlertDescription>
</div>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</Alert>
{user.twoFactorEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the
event that you lose access to your authenticator app.
</AlertDescription>
</div>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</Alert>
)}
</div>
) : (
<Alert className="p-6" variant="neutral">
<AlertTitle>
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</AlertTitle>
{user.twoFactorEnabled ? (
<DisableAuthenticatorAppDialog />
) : (
<EnableAuthenticatorAppDialog />
)}
</Alert>
{user.twoFactorEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the event
that you lose access to your authenticator app.
</AlertDescription>
</div>
<ViewRecoveryCodesDialog />
</Alert>
)}
{isPasskeyEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Passkeys</AlertTitle>
<AlertDescription className="mr-4">
Allows authenticating using biometrics, password managers, hardware keys, etc.
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link href="/settings/security/passkeys">Manage passkeys</Link>
</Button>
<AlertDescription>
To update your password, enable two-factor authentication, and manage other security
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
settings.
</AlertDescription>
</Alert>
)}
@@ -107,7 +91,7 @@ export default async function SecuritySettingsPage() {
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Button asChild>
<Link href="/settings/security/activity">View activity</Link>
</Button>
</Alert>
@@ -1,237 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
export type CreatePasskeyDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({
passkeyName: z.string().min(3),
});
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
const parser = new UAParser();
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const { toast } = useToast();
const form = useForm<TCreatePasskeyFormSchema>({
resolver: zodResolver(ZCreatePasskeyFormSchema),
defaultValues: {
passkeyName: '',
},
});
const { mutateAsync: createPasskeyRegistrationOptions, isLoading } =
trpc.auth.createPasskeyRegistrationOptions.useMutation();
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
setFormError(null);
try {
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
const registrationResult = await startRegistration(passkeyRegistrationOptions);
await createPasskey({
passkeyName,
verificationResponse: registrationResult,
});
toast({
description: 'Successfully created passkey',
duration: 5000,
});
onSuccess?.();
setOpen(false);
} catch (err) {
if (err.name === 'NotAllowedError') {
return;
}
const error = AppError.parseError(err);
setFormError(err.code || error.code);
}
};
const extractDefaultPasskeyName = () => {
if (!window || !window.navigator) {
return;
}
parser.setUA(window.navigator.userAgent);
const result = parser.getResult();
const operatingSystem = result.os.name;
const browser = result.browser.name;
let passkeyName = '';
if (operatingSystem && browser) {
passkeyName = `${browser} (${operatingSystem})`;
}
return passkeyName;
};
useEffect(() => {
if (!open) {
const defaultPasskeyName = extractDefaultPasskeyName();
form.reset({
passkeyName: defaultPasskeyName,
});
setFormError(null);
}
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="secondary" loading={isLoading}>
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
Add passkey
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Add passkey</DialogTitle>
<DialogDescription className="mt-4">
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="passkeyName"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey name</FormLabel>
<FormControl>
<Input className="bg-background" placeholder="eg. Mac" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="neutral">
<AlertDescription>
When you click continue, you will be prompted to add the first available
authenticator on your system.
</AlertDescription>
<AlertDescription className="mt-2">
If you do not want to use the authenticator prompted, you can close it, which will
then display the next available authenticator.
</AlertDescription>
</Alert>
{formError && (
<Alert variant="destructive">
{match(formError)
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
<AlertDescription>This passkey has already been registered.</AlertDescription>
))
.with('TOO_MANY_PASSKEYS', () => (
<AlertDescription>
You cannot have more than {MAXIMUM_PASSKEYS} passkeys.
</AlertDescription>
))
.with('InvalidStateError', () => (
<>
<AlertTitle className="text-sm">
Passkey creation cancelled due to one of the following reasons:
</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-inside list-disc">
<li>Cancelled by user</li>
<li>Passkey already exists for the provided authenticator</li>
<li>Exceeded timeout</li>
</ul>
</AlertDescription>
</>
))
.otherwise(() => (
<AlertDescription>
Something went wrong. Please try again or contact support.
</AlertDescription>
))}
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
Continue
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,33 +0,0 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { CreatePasskeyDialog } from './create-passkey-dialog';
import { UserPasskeysDataTable } from './user-passkeys-data-table';
export const metadata: Metadata = {
title: 'Manage passkeys',
};
export default async function SettingsManagePasskeysPage() {
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
if (!isPasskeyEnabled) {
redirect('/settings/security');
}
return (
<div>
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
<CreatePasskeyDialog />
</SettingsHeader>
<div className="mt-4">
<UserPasskeysDataTable />
</div>
</div>
);
}
@@ -1,200 +0,0 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
export type UserPasskeysDataTableActionsProps = {
className?: string;
passkeyId: string;
passkeyName: string;
};
const ZUpdatePasskeySchema = z.object({
name: z.string(),
});
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
export const UserPasskeysDataTableActions = ({
className,
passkeyId,
passkeyName,
}: UserPasskeysDataTableActionsProps) => {
const { toast } = useToast();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
const form = useForm<TUpdatePasskeySchema>({
resolver: zodResolver(ZUpdatePasskeySchema),
defaultValues: {
name: passkeyName,
},
});
const { mutateAsync: updatePasskey, isLoading: isUpdatingPasskey } =
trpc.auth.updatePasskey.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Passkey has been updated',
});
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We are unable to update this passkey at the moment. Please try again later.',
duration: 10000,
variant: 'destructive',
});
},
});
const { mutateAsync: deletePasskey, isLoading: isDeletingPasskey } =
trpc.auth.deletePasskey.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Passkey has been removed',
});
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We are unable to remove this passkey at the moment. Please try again later.',
duration: 10000,
variant: 'destructive',
});
},
});
return (
<div className={cn('flex justify-end space-x-2', className)}>
<Dialog
open={isUpdateDialogOpen}
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="outline">Edit</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Update passkey</DialogTitle>
<DialogDescription className="mt-4">
You are currently updating the <strong>{passkeyName}</strong> passkey.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(async ({ name }) =>
updatePasskey({
passkeyId,
name,
}),
)}
>
<fieldset className="flex h-full flex-col" disabled={isUpdatingPasskey}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button type="submit" loading={isUpdatingPasskey}>
Update
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
<Button variant="destructive">Delete</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Delete passkey</DialogTitle>
<DialogDescription className="mt-4">
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
</DialogDescription>
</DialogHeader>
<fieldset disabled={isDeletingPasskey}>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button
onClick={async () =>
deletePasskey({
passkeyId,
})
}
variant="destructive"
loading={isDeletingPasskey}
>
Delete
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
</div>
);
};
@@ -1,120 +0,0 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { DateTime } from 'luxon';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
export const UserPasskeysDataTable = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
{
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<DataTable
columns={[
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
},
{
header: 'Last used',
accessorKey: 'updatedAt',
cell: ({ row }) =>
row.original.lastUsedAt
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
: 'Never',
},
{
id: 'actions',
cell: ({ row }) => (
<UserPasskeysDataTableActions
className="justify-end"
passkeyId={row.original.id}
passkeyName={row.original.name}
/>
),
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
onClearFilters={() => router.push(pathname ?? '/')}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading && isInitialLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row space-x-2">
<Skeleton className="h-8 w-16 rounded" />
<Skeleton className="h-8 w-16 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination table={table} />}
</DataTable>
);
};
@@ -6,9 +6,7 @@ import { getServerSession } from 'next-auth';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
@@ -19,7 +17,6 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
import { DocumentPreviewButton } from './document-preview-button';
export type CompletedSigningPageProps = {
@@ -35,11 +32,8 @@ export default async function CompletedSigningPage({
return notFound();
}
const { user } = await getServerComponentSession();
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document || !document.documentData) {
@@ -59,17 +53,6 @@ export default async function CompletedSigningPage({
return notFound();
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document,
recipient,
userId: user?.id,
});
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
}
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
const recipientName =
@@ -11,9 +11,6 @@ import {
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -41,12 +38,12 @@ export const DateField = ({
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
} = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
@@ -56,23 +53,16 @@ export const DateField = ({
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
const onSign = async (authOptions?: TRecipientActionAuth) => {
const onSign = async () => {
try {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
authOptions,
});
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -1,172 +0,0 @@
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>
);
};
@@ -1,79 +0,0 @@
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,90 +0,0 @@
import { P, match } from 'ts-pattern';
import {
DocumentAuth,
type TRecipientActionAuth,
type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth';
import type { FieldType } from '@documenso/prisma/client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} 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';
export type DocumentActionAuthDialogProps = {
title?: string;
documentAuthType: TRecipientActionAuthTypes;
description?: string;
actionTarget: FieldType | 'DOCUMENT';
open: boolean;
onOpenChange: (value: boolean) => void;
/**
* The callback to run when the reauth form is filled out.
*/
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
export const DocumentActionAuthDialog = ({
title,
description,
documentAuthType,
open,
onOpenChange,
onReauthFormSubmit,
}: DocumentActionAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext();
const handleOnOpenChange = (value: boolean) => {
if (isCurrentlyAuthenticating) {
return;
}
onOpenChange(value);
};
return (
<Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title || 'Sign field'}</DialogTitle>
<DialogDescription>
{description || 'Reauthentication is required to sign this field'}
</DialogDescription>
</DialogHeader>
{match({ documentAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentActionAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
<DocumentActionAuthPasskey
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentActionAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent>
</Dialog>
);
};
@@ -1,252 +0,0 @@
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,230 +0,0 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { match } from 'ts-pattern';
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import type {
TDocumentAuthOptions,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
TRecipientAuthOptions,
} from '@documenso/lib/types/document-auth';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
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 { DocumentActionAuthDialog } from './document-action-auth-dialog';
type PasskeyData = {
passkeys: Omit<Passkey, 'credentialId' | 'credentialPublicKey'>[];
isInitialLoading: boolean;
isRefetching: boolean;
isError: boolean;
};
export type DocumentAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
document: Document;
documentAuthOption: TDocumentAuthOptions;
setDocument: (_value: Document) => void;
recipient: Recipient;
recipientAuthOption: TRecipientAuthOptions;
setRecipient: (_value: Recipient) => void;
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
isAuthRedirectRequired: boolean;
isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void;
passkeyData: PasskeyData;
preferredPasskeyId: string | null;
setPreferredPasskeyId: (_value: string | null) => void;
user?: User | null;
refetchPasskeys: () => Promise<void>;
};
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
export const useDocumentAuthContext = () => {
return useContext(DocumentAuthContext);
};
export const useRequiredDocumentAuthContext = () => {
const context = useDocumentAuthContext();
if (!context) {
throw new Error('Document auth context is required');
}
return context;
};
export interface DocumentAuthProviderProps {
document: Document;
recipient: Recipient;
user?: User | null;
children: React.ReactNode;
}
export const DocumentAuthProvider = ({
document: initialDocument,
recipient: initialRecipient,
user,
children,
}: DocumentAuthProviderProps) => {
const [document, setDocument] = useState(initialDocument);
const [recipient, setRecipient] = useState(initialRecipient);
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(null);
const {
documentAuthOption,
recipientAuthOption,
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
} = useMemo(
() =>
extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
}),
[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] =
useState<ExecuteActionAuthProcedureOptions | null>(null);
/**
* The pre calculated auth payload if the current user is authenticated correctly
* for the `derivedRecipientActionAuth`.
*
* Will be `null` if the user still requires authentication, or if they don't need
* authentication.
*/
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth)
.with(DocumentAuth.ACCOUNT, () => {
if (recipient.email !== user?.email) {
return null;
}
return {
type: DocumentAuth.ACCOUNT,
};
})
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
.exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// Directly run callback if no auth required.
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
await options.onReauthFormSubmit();
return;
}
// Run callback with precalculated auth options if available.
if (preCalculatedActionAuthOptions) {
setDocumentAuthDialogPayload(null);
await options.onReauthFormSubmit(preCalculatedActionAuthOptions);
return;
}
// Request the required auth from the user.
setDocumentAuthDialogPayload({
...options,
});
};
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(
derivedRecipientActionAuth &&
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
user?.email !== recipient.email,
);
const refetchPasskeys = async () => {
await passkeyQuery.refetch();
};
return (
<DocumentAuthContext.Provider
value={{
user,
document,
setDocument,
executeActionAuthProcedure,
recipient,
setRecipient,
documentAuthOption,
recipientAuthOption,
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
isAuthRedirectRequired,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
passkeyData,
preferredPasskeyId,
setPreferredPasskeyId,
refetchPasskeys,
}}
>
{children}
{documentAuthDialogPayload && derivedRecipientActionAuth && (
<DocumentActionAuthDialog
open={true}
onOpenChange={() => setDocumentAuthDialogPayload(null)}
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
actionTarget={documentAuthDialogPayload.actionTarget}
documentAuthType={derivedRecipientActionAuth}
/>
)}
</DocumentAuthContext.Provider>
);
};
type ExecuteActionAuthProcedureOptions = Omit<
DocumentActionAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
>;
DocumentAuthProvider.displayName = 'DocumentAuthProvider';
@@ -6,9 +6,6 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -32,33 +29,26 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
} = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const onSign = async (authOptions?: TRecipientActionAuth) => {
const onSign = async () => {
try {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: providedEmail ?? '',
isBase64: false,
authOptions,
});
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -8,7 +8,6 @@ import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@@ -42,10 +41,10 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
const {
handleSubmit,
formState: { isSubmitting },
} = useForm();
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
@@ -65,20 +64,9 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
return;
}
await completeDocument();
// Reauth is currently not required for completing the document.
// await executeActionAuthProcedure({
// onReauthFormSubmit: completeDocument,
// actionTarget: 'DOCUMENT',
// });
};
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
authOptions,
});
analytics.capture('App: Recipient has completed signing', {
@@ -6,10 +6,7 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,7 +15,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
@@ -35,50 +31,24 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
const { fullName: providedFullName, setFullName: setProvidedFullName } =
useRequiredSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
} = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showFullNameModal, setShowFullNameModal] = useState(false);
const [localFullName, setLocalFullName] = useState('');
const onPreSign = () => {
if (!providedFullName) {
setShowFullNameModal(true);
return false;
}
return true;
};
/**
* When the user clicks the sign button in the dialog where they enter their full name.
*/
const onDialogSignClick = () => {
setShowFullNameModal(false);
setProvidedFullName(localFullName);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName),
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
const value = name || providedFullName;
if (!value) {
if (!providedFullName && !localFullName) {
setShowFullNameModal(true);
return;
}
@@ -86,19 +56,18 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value,
value: source === 'local' && localFullName ? localFullName : providedFullName ?? '',
isBase64: false,
authOptions,
});
if (source === 'local' && !providedFullName) {
setProvidedFullName(localFullName);
}
setLocalFullName('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -129,13 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
};
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Name"
>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@@ -184,7 +147,10 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
type="button"
className="flex-1"
disabled={!localFullName}
onClick={() => onDialogSignClick()}
onClick={() => {
setShowFullNameModal(false);
void onSign('local');
}}
>
Sign
</Button>
@@ -1,24 +1,35 @@
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentAuthProvider } from './document-auth-provider';
import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { NoLongerAvailable } from './no-longer-available';
import { SigningProvider } from './provider';
import { SigningAuthPageView } from './signing-auth-page';
import { SigningPageView } from './signing-page-view';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
export type SigningPageProps = {
params: {
@@ -31,8 +42,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const { user } = await getServerComponentSession();
const requestHeaders = Object.fromEntries(headers().entries());
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
@@ -40,40 +49,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
requireAccessAuth: false,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token, requestMetadata }).catch(() => null),
]);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const truncatedTitle = truncateTitle(document.title);
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document,
recipient,
userId: user?.id,
});
const { documentData, documentMeta } = document;
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
const { documentMeta } = document;
const { user } = await getServerComponentSession();
if (
document.status === DocumentStatus.COMPLETED ||
@@ -119,9 +109,73 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
fullName={user?.email === recipient.email ? user.name : recipient.name}
signature={user?.email === recipient.email ? user.signature : undefined}
>
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
<SigningPageView recipient={recipient} document={document} fields={fields} />
</DocumentAuthProvider>
<div className="mx-auto w-full max-w-screen-xl">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{truncatedTitle}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to{' '}
{recipient.role === RecipientRole.VIEWER && 'view'}
{recipient.role === RecipientRole.SIGNER && 'sign'}
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
</p>
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>
</div>
</SigningProvider>
);
}
@@ -33,28 +33,8 @@ export const SignDialog = ({
const truncatedTitle = truncateTitle(document.title);
const isComplete = fields.every((field) => field.inserted);
const handleOpenChange = (open: boolean) => {
if (isSubmitting || !isComplete) {
return;
}
// Reauth is currently not required for signing the document.
// if (isAuthRedirectRequired) {
// await executeActionAuthProcedure({
// actionTarget: 'DOCUMENT',
// onReauthFormSubmit: () => {
// // Do nothing since the user should be redirected.
// },
// });
// return;
// }
setShowDialog(open);
};
return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button
className="w-full"
@@ -1,15 +1,12 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { useEffect, useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,7 +15,6 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
@@ -33,21 +29,18 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
} = trpc.field.removeSignedFieldWithToken.useMutation();
const { Signature: signature } = field;
@@ -55,6 +48,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [localSignature, setLocalSignature] = useState<string | null>(null);
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
const state = useMemo<SignatureFieldState>(() => {
if (!field.inserted) {
@@ -68,38 +62,23 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return 'signed-text';
}, [field.inserted, signature?.signatureImageAsBase64]);
const onPreSign = () => {
if (!providedSignature) {
setShowSignatureModal(true);
return false;
useEffect(() => {
if (!showSignatureModal && !isLocalSignatureSet) {
setLocalSignature(null);
}
}, [showSignatureModal, isLocalSignatureSet]);
return true;
};
/**
* When the user clicks the sign button in the dialog where they enter their signature.
*/
const onDialogSignClick = () => {
setShowSignatureModal(false);
setProvidedSignature(localSignature);
if (!localSignature) {
return;
}
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
const value = signature || providedSignature;
if (!providedSignature && !localSignature) {
setIsLocalSignatureSet(false);
setShowSignatureModal(true);
return;
}
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
if (!value) {
setShowSignatureModal(true);
return;
}
@@ -108,17 +87,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
fieldId: field.id,
value,
isBase64: true,
authOptions,
});
if (source === 'local' && !providedSignature) {
setProvidedSignature(localSignature);
}
setLocalSignature(null);
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -149,13 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
};
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@@ -218,7 +190,11 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
type="button"
className="flex-1"
disabled={!localSignature}
onClick={() => onDialogSignClick()}
onClick={() => {
setShowSignatureModal(false);
setIsLocalSignatureSet(true);
void onSign('local');
}}
>
Sign
</Button>
@@ -1,67 +0,0 @@
'use client';
import { useState } from 'react';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type SigningAuthPageViewProps = {
email: string;
};
export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
const { toast } = useToast();
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 {
toast({
title: 'Something went wrong',
description: 'We were unable to log you out at this time.',
duration: 10000,
variant: 'destructive',
});
}
setIsSigningOut(false);
};
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<h1 className="text-3xl font-semibold">Authentication required</h1>
<p className="text-muted-foreground mt-2 text-sm">
You need to be logged in as <strong>{email}</strong> to view this page.
</p>
<Button
className="mt-4 w-full"
type="submit"
onClick={async () => handleChangeAccount(email)}
loading={isSigningOut}
>
Login
</Button>
</div>
</div>
);
};
@@ -2,38 +2,15 @@
import React from 'react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { FieldType } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type SignatureFieldProps = {
field: FieldWithSignature;
loading?: boolean;
children: React.ReactNode;
/**
* A function that is called before the field requires to be signed, or reauthed.
*
* Example, you may want to show a dialog prior to signing where they can enter a value.
*
* Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed
* regardless if it requires reauth or not.
*
* If the function returns true, we will proceed with the signing process. Otherwise if
* false is returned we will not proceed.
*/
onPreSign?: () => Promise<boolean> | boolean;
/**
* The function required to be executed to insert the field.
*
* The auth values will be passed in if available.
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
onSign?: () => Promise<void> | void;
onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
tooltipText?: string | null;
@@ -42,56 +19,18 @@ export type SignatureFieldProps = {
export const SigningFieldContainer = ({
field,
loading,
onPreSign,
onSign,
onRemove,
children,
type,
tooltipText,
}: SignatureFieldProps) => {
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
const handleInsertField = async () => {
if (field.inserted || !onSign) {
const onSignFieldClick = async () => {
if (field.inserted) {
return;
}
// Bypass reauth for non signature fields.
if (field.type !== FieldType.SIGNATURE) {
const presignResult = await onPreSign?.();
if (presignResult === false) {
return;
}
await onSign();
return;
}
if (isAuthRedirectRequired) {
await executeActionAuthProcedure({
onReauthFormSubmit: () => {
// Do nothing since the user should be redirected.
},
actionTarget: field.type,
});
return;
}
// Handle any presign requirements, and halt if required.
if (onPreSign) {
const preSignResult = await onPreSign();
if (preSignResult === false) {
return;
}
}
await executeActionAuthProcedure({
onReauthFormSubmit: onSign,
actionTarget: field.type,
});
await onSign?.();
};
const onRemoveSignedFieldClick = async () => {
@@ -108,7 +47,7 @@ export const SigningFieldContainer = ({
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full"
onClick={async () => handleInsertField()}
onClick={onSignFieldClick}
/>
)}
@@ -1,102 +0,0 @@
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
export type SigningPageViewProps = {
document: DocumentAndSender;
recipient: Recipient;
fields: Field[];
};
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document;
return (
<div className="mx-auto w-full max-w-screen-xl">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{truncatedTitle}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to{' '}
{recipient.role === RecipientRole.VIEWER && 'view'}
{recipient.role === RecipientRole.SIGNER && 'sign'}
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
</p>
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>
</div>
);
};
@@ -6,9 +6,6 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -18,7 +15,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type TextFieldProps = {
@@ -31,52 +27,36 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
const { toast } = useToast();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
} = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState('');
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
useEffect(() => {
if (!showCustomTextModal) {
if (!showCustomTextModal && !isLocalSignatureSet) {
setLocalCustomText('');
}
}, [showCustomTextModal]);
}, [showCustomTextModal, isLocalSignatureSet]);
/**
* When the user clicks the sign button in the dialog where they enter the text field.
*/
const onDialogSignClick = () => {
setShowCustomTextModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
};
const onPreSign = () => {
if (!localText) {
setShowCustomTextModal(true);
return false;
}
return true;
};
const onSign = async (authOptions?: TRecipientActionAuth) => {
const onSign = async () => {
try {
if (!localText) {
setIsLocalSignatureSet(false);
setShowCustomTextModal(true);
return;
}
if (!localText) {
return;
}
@@ -86,19 +66,12 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
fieldId: field.id,
value: localText,
isBase64: true,
authOptions,
});
setLocalCustomText('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -129,13 +102,7 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
};
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@@ -182,7 +149,11 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
type="button"
className="flex-1"
disabled={!localText}
onClick={() => onDialogSignClick()}
onClick={() => {
setShowCustomTextModal(false);
setIsLocalSignatureSet(true);
void onSign();
}}
>
Save Text
</Button>
@@ -2,7 +2,7 @@ import React from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { Recipient } from '@documenso/prisma/client';
import { StackAvatar } from './stack-avatar';
@@ -14,10 +14,6 @@ import {
SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { Document, Recipient } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
@@ -86,10 +82,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
},
{
keepPreviousData: true,
// Do not batch this due to relatively long request time compared to
// other queries which are generally batched with this.
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@@ -5,18 +5,11 @@ import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = {
title: string;
subtitle: string;
hideDivider?: boolean;
children?: React.ReactNode;
className?: string;
};
export const SettingsHeader = ({
children,
title,
subtitle,
className,
hideDivider,
}: SettingsHeaderProps) => {
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
return (
<>
<div className={cn('flex flex-row items-center justify-between', className)}>
@@ -29,7 +22,7 @@ export const SettingsHeader = ({
{children}
</div>
{!hideDivider && <hr className="my-4" />}
<hr className="my-4" />
</>
);
};
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SVGAttributes } from 'react';
import { SVGAttributes } from 'react';
export type LogoProps = SVGAttributes<SVGSVGElement>;
@@ -7,7 +7,6 @@ import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
@@ -80,11 +79,7 @@ export const DocumentHistorySheet = ({
* @param text The text to format
* @returns The formatted text
*/
const formatGenericText = (text?: string | null) => {
if (!text) {
return '';
}
const formatGenericText = (text: string) => {
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
};
@@ -224,24 +219,6 @@ export const DocumentHistorySheet = ({
/>
),
)
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
},
{
key: 'New',
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
},
]}
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
if (data.changes.length === 0) {
return null;
@@ -304,7 +281,6 @@ export const DocumentHistorySheet = ({
]}
/>
))
.exhaustive()}
{isUserDetailsVisible && (
@@ -0,0 +1,34 @@
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils';
export type FormErrorMessageProps = {
className?: string;
error: { message?: string } | undefined;
};
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
return (
<AnimatePresence>
{error && (
<motion.p
initial={{
opacity: 0,
y: -10,
}}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: 10,
}}
className={cn('text-xs text-red-500', className)}
>
{error.message}
</motion.p>
)}
</AnimatePresence>
);
};
@@ -1,9 +1,9 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import { Globe, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
type TemplateTypeIcon = {
@@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
type AuthenticatorAppProps = {
isTwoFactorEnabled: boolean;
};
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
const isEnableDialogOpen = modalState === 'enable';
const isDisableDialogOpen = modalState === 'disable';
return (
<>
<div className="flex-shrink-0">
{isTwoFactorEnabled ? (
<Button variant="destructive" onClick={() => setModalState('disable')}>
Disable 2FA
</Button>
) : (
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
)}
</div>
<EnableAuthenticatorAppDialog
open={isEnableDialogOpen}
onOpenChange={(open) => !open && setModalState(null)}
/>
<DisableAuthenticatorAppDialog
open={isDisableDialogOpen}
onOpenChange={(open) => !open && setModalState(null)}
/>
</>
);
};
@@ -1,7 +1,3 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -13,51 +9,65 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisable2FAForm = z.object({
token: z.string(),
export const ZDisableTwoFactorAuthenticationForm = z.object({
password: z.string().min(6).max(72),
backupCode: z.string(),
});
export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
export type TDisableTwoFactorAuthenticationForm = z.infer<
typeof ZDisableTwoFactorAuthenticationForm
>;
export const DisableAuthenticatorAppDialog = () => {
export type DisableAuthenticatorAppDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DisableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: DisableAuthenticatorAppDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: disableTwoFactorAuthentication } =
trpc.twoFactorAuthentication.disable.useMutation();
const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
const disable2FAForm = useForm<TDisable2FAForm>({
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
defaultValues: {
token: '',
password: '',
backupCode: '',
},
resolver: zodResolver(ZDisable2FAForm),
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
});
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
disableTwoFactorAuthenticationForm.formState;
const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => {
const onDisableTwoFactorAuthenticationFormSubmit = async ({
password,
backupCode,
}: TDisableTwoFactorAuthenticationForm) => {
try {
await disable2FA({ token });
await disableTwoFactorAuthentication({ password, backupCode });
toast({
title: 'Two-factor authentication disabled',
@@ -66,7 +76,7 @@ export const DisableAuthenticatorAppDialog = () => {
});
flushSync(() => {
setIsOpen(false);
onOpenChange(false);
});
router.refresh();
@@ -81,51 +91,74 @@ export const DisableAuthenticatorAppDialog = () => {
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild={true}>
<Button className="flex-shrink-0" variant="destructive">
Disable 2FA
</Button>
</DialogTrigger>
<DialogContent position="center">
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
<DialogHeader>
<DialogTitle>Disable 2FA</DialogTitle>
<DialogTitle>Disable Authenticator App</DialogTitle>
<DialogDescription>
Please provide a token from the authenticator, or a backup code. If you do not have a
backup code available, please contact support.
To disable the Authenticator App for your account, please enter your password and a
backup code. If you do not have a backup code available, please contact support.
</DialogDescription>
</DialogHeader>
<Form {...disable2FAForm}>
<form onSubmit={disable2FAForm.handleSubmit(onDisable2FAFormSubmit)}>
<fieldset className="flex flex-col gap-y-4" disabled={isDisable2FASubmitting}>
<Form {...disableTwoFactorAuthenticationForm}>
<form
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
onDisableTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<fieldset
className="flex flex-col gap-y-4"
disabled={isDisableTwoFactorAuthenticationSubmitting}
>
<FormField
name="token"
control={disable2FAForm.control}
name="password"
control={disableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<Input {...field} placeholder="Token" />
<PasswordInput
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
Disable 2FA
</Button>
</DialogFooter>
<FormField
name="backupCode"
control={disableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isDisableTwoFactorAuthenticationSubmitting}
>
Disable 2FA
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
@@ -1,11 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { renderSVG } from 'uqr';
import { z } from 'zod';
@@ -14,13 +11,11 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
@@ -31,79 +26,98 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
export const ZEnable2FAForm = z.object({
export const ZSetupTwoFactorAuthenticationForm = z.object({
password: z.string().min(6).max(72),
});
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
export const ZEnableTwoFactorAuthenticationForm = z.object({
token: z.string(),
});
export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
export type EnableAuthenticatorAppDialogProps = {
onSuccess?: () => void;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
export const EnableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: EnableAuthenticatorAppDialogProps) => {
const { toast } = useToast();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
trpc.twoFactorAuthentication.setup.useMutation();
const {
mutateAsync: setup2FA,
data: setup2FAData,
isLoading: isSettingUp2FA,
} = trpc.twoFactorAuthentication.setup.useMutation({
onError: () => {
toast({
title: 'Unable to setup two-factor authentication',
description:
'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',
});
mutateAsync: enableTwoFactorAuthentication,
data: enableTwoFactorAuthenticationData,
isLoading: isEnableTwoFactorAuthenticationDataLoading,
} = trpc.twoFactorAuthentication.enable.useMutation();
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
defaultValues: {
password: '',
},
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
});
const enable2FAForm = useForm<TEnable2FAForm>({
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
setupTwoFactorAuthenticationForm.formState;
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
defaultValues: {
token: '',
},
resolver: zodResolver(ZEnable2FAForm),
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
});
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
enableTwoFactorAuthenticationForm.formState;
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
const step = useMemo(() => {
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
return 'setup';
}
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
return 'enable';
}
return 'view';
}, [
setupTwoFactorAuthenticationData,
isSetupTwoFactorAuthenticationSubmitting,
enableTwoFactorAuthenticationData,
isEnableTwoFactorAuthenticationSubmitting,
]);
const onSetupTwoFactorAuthenticationFormSubmit = async ({
password,
}: TSetupTwoFactorAuthenticationForm) => {
try {
const data = await enable2FA({ code: token });
setRecoveryCodes(data.recoveryCodes);
onSuccess?.();
toast({
title: 'Two-factor authentication enabled',
description:
'You will now be required to enter a code from your authenticator app when signing in.',
});
await setupTwoFactorAuthentication({ password });
} catch (_err) {
toast({
title: 'Unable to setup two-factor authentication',
description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.',
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
}
};
const downloadRecoveryCodes = () => {
if (recoveryCodes) {
const blob = new Blob([recoveryCodes.join('\n')], {
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
type: 'text/plain',
});
@@ -114,126 +128,175 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
}
};
const handleEnable2FA = async () => {
if (!setup2FAData) {
await setup2FA();
}
const onEnableTwoFactorAuthenticationFormSubmit = async ({
token,
}: TEnableTwoFactorAuthenticationForm) => {
try {
await enableTwoFactorAuthentication({ code: token });
setIsOpen(true);
toast({
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
});
} catch (_err) {
toast({
title: 'Unable to setup two-factor authentication',
description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
}
};
useEffect(() => {
enable2FAForm.reset();
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null);
router.refresh();
// Reset the form when the Dialog closes
if (!open) {
setupTwoFactorAuthenticationForm.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
}, [open, setupTwoFactorAuthenticationForm]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild={true}>
<Button
className="flex-shrink-0"
loading={isSettingUp2FA}
onClick={(e) => {
e.preventDefault();
void handleEnable2FA();
}}
>
Enable 2FA
</Button>
</DialogTrigger>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
<DialogHeader>
<DialogTitle>Enable Authenticator App</DialogTitle>
<DialogContent position="center">
{setup2FAData && (
<>
{recoveryCodes ? (
<div>
<DialogHeader>
<DialogTitle>Backup codes</DialogTitle>
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
</DialogHeader>
{step === 'setup' && (
<DialogDescription>
To enable two-factor authentication, please enter your password below.
</DialogDescription>
)}
<div className="mt-4">
<RecoveryCodeList recoveryCodes={recoveryCodes} />
</div>
{step === 'view' && (
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
{match(step)
.with('setup', () => {
return (
<Form {...setupTwoFactorAuthenticationForm}>
<form
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
onSetupTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={setupTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<PasswordInput
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button onClick={downloadRecoveryCodes}>Download</Button>
</DialogFooter>
</div>
) : (
<Form {...enable2FAForm}>
<form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
<DialogHeader>
<DialogTitle>Enable Authenticator App</DialogTitle>
<DialogDescription>
To enable two-factor authentication, scan the following QR code using your
authenticator app.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4">
<div
className="flex h-36 justify-center"
dangerouslySetInnerHTML={{
__html: renderSVG(setup2FAData?.uri ?? ''),
}}
/>
<p className="text-muted-foreground text-sm">
If your authenticator app does not support QR codes, you can use the following
code instead:
</p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setup2FAData?.secret}
</p>
<p className="text-muted-foreground text-sm">
Once you have scanned the QR code or entered the code manually, enter the code
provided by your authenticator app below.
</p>
<FormField
name="token"
control={enable2FAForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button type="submit" loading={isEnabling2FA}>
Enable 2FA
</Button>
</DialogFooter>
</fieldset>
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
Continue
</Button>
</DialogFooter>
</form>
</Form>
)}
</>
)}
);
})
.with('enable', () => (
<Form {...enableTwoFactorAuthenticationForm}>
<form
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
onEnableTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<p className="text-muted-foreground text-sm">
To enable two-factor authentication, scan the following QR code using your
authenticator app.
</p>
<div
className="flex h-36 justify-center"
dangerouslySetInnerHTML={{
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
}}
/>
<p className="text-muted-foreground text-sm">
If your authenticator app does not support QR codes, you can use the following
code instead:
</p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setupTwoFactorAuthenticationData?.secret}
</p>
<p className="text-muted-foreground text-sm">
Once you have scanned the QR code or entered the code manually, enter the code
provided by your authenticator app below.
</p>
<FormField
name="token"
control={enableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
Enable 2FA
</Button>
</DialogFooter>
</form>
</Form>
))
.with('view', () => (
<div>
{enableTwoFactorAuthenticationData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
)}
<div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
onClick={downloadRecoveryCodes}
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
loading={isEnableTwoFactorAuthenticationDataLoading}
>
Download
</Button>
</div>
</div>
))
.exhaustive()}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,33 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
type RecoveryCodesProps = {
isTwoFactorEnabled: boolean;
};
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
className="flex-shrink-0"
onClick={() => setIsOpen(true)}
disabled={!isTwoFactorEnabled}
>
View Codes
</Button>
<ViewRecoveryCodesDialog
key={isOpen ? 'open' : 'closed'}
open={isOpen}
onOpenChange={setIsOpen}
/>
</>
);
};
@@ -1,6 +1,4 @@
'use client';
import { useState } from 'react';
import { useEffect, useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@@ -8,58 +6,69 @@ import { match } from 'ts-pattern';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { AppError } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
export const ZViewRecoveryCodesForm = z.object({
token: z.string().min(1, { message: 'Token is required' }),
password: z.string().min(6).max(72),
});
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
export const ViewRecoveryCodesDialog = () => {
const [isOpen, setIsOpen] = useState(false);
export type ViewRecoveryCodesDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
const { toast } = useToast();
const {
data: recoveryCodes,
mutate,
isLoading,
error,
mutateAsync: viewRecoveryCodes,
data: viewRecoveryCodesData,
isLoading: isViewRecoveryCodesDataLoading,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: {
token: '',
password: '',
},
resolver: zodResolver(ZViewRecoveryCodesForm),
});
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
const step = useMemo(() => {
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
return 'authenticate';
}
return 'view';
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
const downloadRecoveryCodes = () => {
if (recoveryCodes) {
const blob = new Blob([recoveryCodes.join('\n')], {
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
type: 'text/plain',
});
@@ -70,88 +79,105 @@ export const ViewRecoveryCodesDialog = () => {
}
};
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
try {
await viewRecoveryCodes({ password });
} catch (_err) {
toast({
title: 'Unable to view recovery codes',
description:
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
}
};
useEffect(() => {
// Reset the form when the Dialog closes
if (!open) {
viewRecoveryCodesForm.reset();
}
}, [open, viewRecoveryCodesForm]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button className="flex-shrink-0">View Codes</Button>
</DialogTrigger>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
{recoveryCodes ? (
<div>
<DialogHeader className="mb-4">
<DialogTitle>View Recovery Codes</DialogTitle>
<DialogHeader>
<DialogTitle>View Recovery Codes</DialogTitle>
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
</DialogHeader>
{step === 'authenticate' && (
<DialogDescription>
To view your recovery codes, please enter your password below.
</DialogDescription>
)}
<RecoveryCodeList recoveryCodes={recoveryCodes} />
{step === 'view' && (
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
{match(step)
.with('authenticate', () => {
return (
<Form {...viewRecoveryCodesForm}>
<form
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={viewRecoveryCodesForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<PasswordInput
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button onClick={downloadRecoveryCodes}>Download</Button>
</DialogFooter>
</div>
) : (
<Form {...viewRecoveryCodesForm}>
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
<DialogHeader className="mb-4">
<DialogTitle>View Recovery Codes</DialogTitle>
<DialogDescription>
Please provide a token from your authenticator, or a backup code.
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isLoading}>
<FormField
name="token"
control={viewRecoveryCodesForm.control}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>
{match(AppError.parseError(error).message)
.with(
ErrorCode.INCORRECT_TWO_FACTOR_CODE,
() => 'Invalid code. Please try again.',
)
.otherwise(
() => 'Something went wrong. Please try again or contact support.',
)}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogClose>
<Button type="submit" loading={isLoading}>
View
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
Continue
</Button>
</DialogFooter>
</form>
</Form>
);
})
.with('view', () => (
<div>
{viewRecoveryCodesData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)}
<div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
disabled={!viewRecoveryCodesData?.recoveryCodes}
loading={isViewRecoveryCodesDataLoading}
onClick={downloadRecoveryCodes}
>
Download
</Button>
</div>
</div>
))
.exhaustive()}
</DialogContent>
</Dialog>
);
@@ -55,8 +55,11 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
});
const isSubmitting = form.formState.isSubmitting;
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
try {
+16 -108
View File
@@ -6,18 +6,12 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { trpc } from '@documenso/trpc/react';
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -72,24 +66,14 @@ export type SignInFormProps = {
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
const { toast } = useToast();
const { getFlag } = useFeatureFlags();
const router = useRouter();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const router = useRouter();
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
>('totp');
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const isPasskeyEnabled = getFlag('app_passkey');
const { mutateAsync: createPasskeySigninOptions } =
trpc.auth.createPasskeySigninOptions.useMutation();
const form = useForm<TSignInFormSchema>({
values: {
email: initialEmail ?? '',
@@ -123,63 +107,6 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
setTwoFactorAuthenticationMethod(method);
};
const onSignInWithPasskey = async () => {
if (!browserSupportsWebAuthn()) {
toast({
title: 'Not supported',
description: 'Passkeys are not supported on this browser',
duration: 10000,
variant: 'destructive',
});
return;
}
try {
setIsPasskeyLoading(true);
const options = await createPasskeySigninOptions();
const credential = await startAuthentication(options);
const result = await signIn('webauthn', {
credential: JSON.stringify(credential),
callbackUrl: LOGIN_REDIRECT_PATH,
redirect: false,
});
if (!result?.url || result.error) {
throw new AppError(result?.error ?? '');
}
window.location.href = result.url;
} catch (err) {
setIsPasskeyLoading(false);
if (err.name === 'NotAllowedError') {
return;
}
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_SETUP,
() =>
'This passkey is not configured for this application. Please login and add one in the user settings.',
)
.with(AppErrorCode.EXPIRED_CODE, () => 'This session has expired. Please try again.')
.otherwise(() => 'Please try again later or login using your normal details');
toast({
title: 'Something went wrong',
description: errorMessage,
duration: 10000,
variant: 'destructive',
});
}
};
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
try {
const credentials: Record<string, string> = {
@@ -262,10 +189,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset
className="flex w-full flex-col gap-y-4"
disabled={isSubmitting || isPasskeyLoading}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
@@ -293,8 +217,6 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
<PasswordInput {...field} />
</FormControl>
<FormMessage />
<p className="mt-2 text-right">
<Link
href="/forgot-password"
@@ -303,28 +225,29 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
Forgot your password?
</Link>
</p>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span>
<div className="bg-border h-px flex-1" />
</div>
)}
{isGoogleSSOEnabled && (
<Button
type="button"
size="lg"
@@ -336,23 +259,8 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
)}
{isPasskeyEnabled && (
<Button
type="button"
size="lg"
variant="outline"
disabled={isSubmitting}
loading={isPasskeyLoading}
className="bg-background text-muted-foreground border"
onClick={onSignInWithPasskey}
>
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
Passkey
</Button>
)}
</fieldset>
</>
)}
</form>
<Dialog
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SVGAttributes } from 'react';
import { SVGAttributes } from 'react';
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
@@ -24,7 +24,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm">
{baseUrl.host}/u/{user.url}
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes/dist/types';
import { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
-1
View File
@@ -115,7 +115,6 @@ Here's a markdown table documenting all the provided environment variables:
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
-1
View File
@@ -34,7 +34,6 @@ services:
- NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL:-${NEXT_PRIVATE_DATABASE_URL}}
- NEXT_PUBLIC_UPLOAD_TRANSPORT=${NEXT_PUBLIC_UPLOAD_TRANSPORT:-database}
- NEXT_PRIVATE_UPLOAD_ENDPOINT=${NEXT_PRIVATE_UPLOAD_ENDPOINT}
- NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE=${NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE}
- NEXT_PRIVATE_UPLOAD_REGION=${NEXT_PRIVATE_UPLOAD_REGION}
- NEXT_PRIVATE_UPLOAD_BUCKET=${NEXT_PRIVATE_UPLOAD_BUCKET}
- NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=${NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID}
+9 -169
View File
@@ -42,6 +42,7 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@openstatus/react": "^0.0.3",
"contentlayer": "^0.3.4",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
@@ -100,10 +101,7 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
@@ -132,7 +130,6 @@
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@simplewebauthn/types": "^9.0.1",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
@@ -2632,11 +2629,6 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
},
"node_modules/@hookform/resolvers": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
@@ -3261,11 +3253,6 @@
"node": ">=12"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.2.tgz",
"integrity": "sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw=="
},
"node_modules/@manypkg/find-root": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.1.tgz",
@@ -4138,6 +4125,14 @@
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="
},
"node_modules/@openstatus/react": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@openstatus/react/-/react-0.0.3.tgz",
"integrity": "sha512-uDiegz7e3H67pG8lTT+op+6w5keTT7XpcENrREaqlWl5j53TYyO8nheOG1PeNw2/Qgd5KaGeRJJFn1crhTUSYw==",
"peerDependencies": {
"react": "^18.0.0"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz",
@@ -4625,60 +4620,6 @@
"pako": "^1.0.10"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.10.tgz",
"integrity": "sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.8",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz",
"integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz",
"integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz",
"integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==",
"dependencies": {
"asn1js": "^3.0.5",
"pvtsutils": "^1.3.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz",
"integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.8",
"asn1js": "^3.0.5",
"ipaddr.js": "^2.1.0",
"pvtsutils": "^1.3.5",
"tslib": "^2.6.2"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -6509,38 +6450,6 @@
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
},
"node_modules/@simplewebauthn/browser": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz",
"integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==",
"dependencies": {
"@simplewebauthn/types": "^9.0.1"
}
},
"node_modules/@simplewebauthn/server": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz",
"integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.3.10",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-rsa": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"@simplewebauthn/types": "^9.0.1",
"cross-fetch": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz",
"integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w=="
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -8716,19 +8625,6 @@
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
},
"node_modules/asn1js": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
"integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==",
"dependencies": {
"pvtsutils": "^1.3.2",
"pvutils": "^1.1.3",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
@@ -10081,11 +9977,6 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-es": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.0.0.tgz",
"integrity": "sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ=="
},
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
@@ -10170,33 +10061,6 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-fetch/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -13724,14 +13588,6 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/ipaddr.js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
"integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==",
"engines": {
"node": ">= 10"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -18072,22 +17928,6 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz",
"integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==",
"dependencies": {
"tslib": "^2.6.1"
}
},
"node_modules/pvutils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/qs": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
@@ -1,97 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const document = await seedPendingDocument(user, [
recipientWithAccount,
'recipientwithoutaccount@documenso.com',
]);
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
const tokens = recipients.map((recipient) => recipient.token);
for (const token of tokens) {
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
}
await unseedUser(user.id);
});
test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const document = await seedPendingDocument(
user,
[recipientWithAccount, 'recipientwithoutaccount@documenso.com'],
{
createDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
}),
},
},
);
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
// Check that both are denied access.
for (const recipient of recipients) {
const { email, token } = recipient;
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible();
await expect(page.getByRole('paragraph')).toContainText(email);
}
await apiSignin({
page,
email: recipientWithAccount.email,
redirectPath: '/',
});
// Check that the one logged in is granted access.
for (const recipient of recipients) {
const { email, token } = recipient;
await page.goto(`/sign/${token}`);
// Recipient should be granted access.
if (recipient.email === recipientWithAccount.email) {
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
}
// Recipient should still be denied.
if (recipient.email !== recipientWithAccount.email) {
await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible();
await expect(page.getByRole('paragraph')).toContainText(email);
}
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
@@ -1,418 +0,0 @@
import { expect, test } from '@playwright/test';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
} from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import {
seedPendingDocumentNoFields,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [recipientWithAccount, seedTestEmail()],
});
// Check that both are granted access.
for (const recipient of recipients) {
const { token, Field } = recipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
}),
},
});
const recipient = recipients[0];
const { token, Field } = recipient;
const signUrl = `/sign/${token}`;
await apiSignin({
page,
email: recipientWithAccount.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
// Currently document auth for signing/approving/viewing is not required.
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
page,
}) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const { recipients } = await seedPendingDocumentNoFields({
owner: user,
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
}),
},
});
const recipient = recipients[0];
const { token } = recipient;
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the document',
);
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({
page,
}) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [recipientWithAccount, seedTestEmail()],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
}),
},
});
// Check that both are denied access.
for (const recipient of recipients) {
const { token, Field } = recipient;
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field',
);
await page.getByRole('button', { name: 'Cancel' }).click();
}
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({
page,
}) => {
const user = await seedUser();
const recipientWithInheritAuth = await seedUser();
const recipientWithExplicitNoneAuth = await seedUser();
const recipientWithExplicitAccountAuth = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [
recipientWithInheritAuth,
recipientWithExplicitNoneAuth,
recipientWithExplicitAccountAuth,
],
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
}),
},
],
fields: [FieldType.DATE],
});
for (const recipient of recipients) {
const { token, Field } = recipient;
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document has no global action auth, so only account should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT';
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
if (isAuthRequired) {
for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field',
);
await page.getByRole('button', { name: 'Cancel' }).click();
}
// Sign in and it should work.
await apiSignin({
page,
email: recipient.email,
redirectPath: signUrl,
});
}
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
timeout: 5000,
});
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
if (isAuthRequired) {
await apiSignout({ page });
}
}
});
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({
page,
}) => {
const user = await seedUser();
const recipientWithInheritAuth = await seedUser();
const recipientWithExplicitNoneAuth = await seedUser();
const recipientWithExplicitAccountAuth = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [
recipientWithInheritAuth,
recipientWithExplicitNoneAuth,
recipientWithExplicitAccountAuth,
],
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
}),
},
],
fields: [FieldType.DATE],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
}),
},
});
for (const recipient of recipients) {
const { token, Field } = recipient;
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document HAS global action auth, so account and inherit should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
if (isAuthRequired) {
for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field',
);
await page.getByRole('button', { name: 'Cancel' }).click();
}
// Sign in and it should work.
await apiSignin({
page,
email: recipient.email,
redirectPath: signUrl,
});
}
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
timeout: 5000,
});
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
if (isAuthRequired) {
await apiSignout({ page });
}
}
});
@@ -1,200 +0,0 @@
import { expect, test } from '@playwright/test';
import {
seedBlankDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[DOCUMENT_FLOW] add action auth settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(owner, {
createDocumentOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
await unseedTeam(team.url);
});
test('[DOCUMENT_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(teamMemberUser);
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
await unseedTeam(team.url);
});
});
test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Set title.
await page.getByLabel('Title').fill('New Title');
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: title should be disabled depending on document status', async ({ page }) => {
const user = await seedUser();
const pendingDocument = await seedPendingDocument(user, []);
const draftDocument = await seedDraftDocument(user, []);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${pendingDocument.id}/edit`,
});
// Should be disabled for pending documents.
await expect(page.getByLabel('Title')).toBeDisabled();
// Should be enabled for draft documents.
await page.goto(`/documents/${draftDocument.id}/edit`);
await expect(page.getByLabel('Title')).toBeEnabled();
await unseedUser(user.id);
});
@@ -1,118 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[DOCUMENT_FLOW] add EE settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').click();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id);
});
});
// Note: Not complete yet due to issue with back button.
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// // Add advanced settings for a single recipient.
// await page.getByLabel('Show advanced settings').click();
// await page.getByRole('combobox').first().click();
// await page.getByLabel('Require account').click();
// // Navigate to the next step and back.
// await page.getByRole('button', { name: 'Continue' }).click();
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// await page.getByRole('button', { name: 'Go Back' }).click();
// await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id);
});
@@ -1,8 +1,8 @@
import { type Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
type LoginOptions = {
type ManualLoginOptions = {
page: Page;
email?: string;
password?: string;
@@ -18,7 +18,7 @@ export const manualLogin = async ({
email = 'example@documenso.com',
password = 'password',
redirectPath,
}: LoginOptions) => {
}: ManualLoginOptions) => {
await page.goto(`${WEBAPP_BASE_URL}/signin`);
await page.getByLabel('Email').click();
@@ -33,63 +33,9 @@ export const manualLogin = async ({
}
};
export const manualSignout = async ({ page }: LoginOptions) => {
export const manualSignout = async ({ page }: ManualLoginOptions) => {
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 ({
page,
email = 'example@documenso.com',
password = 'password',
redirectPath = '/',
}: LoginOptions) => {
const { request } = page.context();
const csrfToken = await getCsrfToken(page);
await request.post(`${WEBAPP_BASE_URL}/api/auth/callback/credentials`, {
form: {
email,
password,
json: true,
csrfToken,
},
});
if (redirectPath) {
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
}
};
export const apiSignout = async ({ page }: { page: Page }) => {
const { request } = page.context();
const csrfToken = await getCsrfToken(page);
await request.post(`${WEBAPP_BASE_URL}/api/auth/signout`, {
form: {
csrfToken,
json: true,
},
});
await page.goto(`${WEBAPP_BASE_URL}/signin`);
};
const getCsrfToken = async (page: Page) => {
const { request } = page.context();
const response = await request.fetch(`${WEBAPP_BASE_URL}/api/auth/csrf`, {
method: 'get',
});
const { csrfToken } = await response.json();
if (!csrfToken) {
throw new Error('Invalid session');
}
return csrfToken;
};
@@ -1,36 +1,17 @@
import { expect, test } from '@playwright/test';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './fixtures/authentication';
const getDocumentByToken = async (token: string) => {
return await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
});
};
import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
@@ -47,8 +28,8 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set general settings
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Set title
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
@@ -92,267 +73,3 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
});
test('should be able to create a document with multiple recipients', async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// Upload document
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
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2');
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'User 1 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByText('User 1 (user1@example.com)').click();
await page.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'User 2 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 500,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 500,
y: 200,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
});
test('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();
await apiSignin({
page,
email: user.email,
});
// Upload document
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
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
const url = page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
email: 'user1@example.com',
documentId: Number(documentId),
});
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('You have signed')).toBeVisible();
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
});
test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
page,
}) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// Upload document
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
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
const url = page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
email: 'user1@example.com',
documentId: Number(documentId),
});
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL('https://documenso.com');
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
});
@@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: create team', async ({ page }) => {
const user = await seedUser();
await apiSignin({
await manualLogin({
page,
email: user.email,
redirectPath: '/settings/teams',
@@ -38,7 +38,7 @@ test('[TEAMS]: create team', async ({ page }) => {
test('[TEAMS]: delete team', async ({ page }) => {
const team = await seedTeam();
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
redirectPath: `/t/${team.url}/settings`,
@@ -56,7 +56,7 @@ test('[TEAMS]: delete team', async ({ page }) => {
test('[TEAMS]: update team', async ({ page }) => {
const team = await seedTeam();
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
});
@@ -6,7 +6,7 @@ import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documen
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { manualLogin, manualSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
@@ -30,7 +30,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
for (const user of [team.owner, teamMember2]) {
await apiSignin({
await manualLogin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
@@ -55,7 +55,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await apiSignout({ page });
await manualSignout({ page });
}
await unseedTeam(team.url);
@@ -126,7 +126,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
await manualLogin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
@@ -151,7 +151,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await apiSignout({ page });
await manualSignout({ page });
}
await unseedTeamEmail({ teamId: team.id });
@@ -216,7 +216,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
},
]);
await apiSignin({
await manualLogin({
page,
email: teamMember2.email,
redirectPath: `/t/${team.url}/documents`,
@@ -248,7 +248,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
await manualLogin({
page,
email: currentUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
@@ -266,7 +266,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
test('[TEAMS]: resend pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
await manualLogin({
page,
email: currentUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
@@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: send team email request', async ({ page }) => {
const team = await seedTeam();
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
password: 'password',
@@ -57,7 +57,7 @@ test('[TEAMS]: delete team email', async ({ page }) => {
createTeamEmail: true,
});
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
redirectPath: `/t/${team.url}/settings`,
@@ -86,7 +86,7 @@ test('[TEAMS]: team email owner removes access', async ({ page }) => {
email: team.teamEmail.email,
});
await apiSignin({
await manualLogin({
page,
email: teamEmailOwner.email,
redirectPath: `/settings/teams`,
@@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
@@ -13,7 +13,7 @@ test('[TEAMS]: update team member role', async ({ page }) => {
createTeamMembers: 1,
});
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
password: 'password',
@@ -75,7 +75,7 @@ test('[TEAMS]: member can leave team', async ({ page }) => {
const teamMember = team.members[1];
await apiSignin({
await manualLogin({
page,
email: teamMember.user.email,
password: 'password',
@@ -97,7 +97,7 @@ test('[TEAMS]: owner cannot leave team', async ({ page }) => {
createTeamMembers: 1,
});
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
password: 'password',
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
@@ -14,7 +14,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
const teamMember = team.members[1];
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
password: 'password',
@@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
@@ -36,7 +36,7 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
teamId: team.id,
});
await apiSignin({
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
@@ -81,7 +81,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
teamId: team.id,
});
await apiSignin({
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
@@ -135,7 +135,7 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
teamId: team.id,
});
await apiSignin({
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
@@ -181,7 +181,7 @@ test('[TEMPLATES]: use template', async ({ page }) => {
teamId: team.id,
});
await apiSignin({
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
@@ -1,37 +0,0 @@
import { expect, test } from '@playwright/test';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users';
import { manualLogin } from './fixtures/authentication';
test('update user name', async ({ page }) => {
const user = await seedUser();
await manualLogin({
page,
email: user.email,
redirectPath: '/settings/profile',
});
await page.getByLabel('Full Name').fill('John Doe');
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Update profile' }).click();
// wait for it to finish
await expect(page.getByText('Profile updated', { exact: true })).toBeVisible();
await page.waitForURL('/settings/profile');
expect((await getUserByEmail({ email: user.email })).name).toEqual('John Doe');
});
-1
View File
@@ -6,7 +6,6 @@
"main": "index.js",
"scripts": {
"test:dev": "playwright test",
"test-ui:dev": "playwright test --ui",
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
},
"keywords": [],
+5 -9
View File
@@ -1,14 +1,10 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
ENV_FILES.forEach((file) => {
dotenv.config({
path: path.join(__dirname, `../../${file}`),
});
});
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
@@ -1,56 +0,0 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainActiveEnterprisePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
export type IsUserEnterpriseOptions = {
userId: number;
teamId?: number;
};
/**
* Whether the user is enterprise, or has permission to use enterprise features on
* behalf of their team.
*
* It is assumed that the provided user is part of the provided team.
*/
export const isUserEnterprise = async ({
userId,
teamId,
}: IsUserEnterpriseOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (!IS_BILLING_ENABLED()) {
return false;
}
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
Subscription: true,
},
},
},
})
.then((team) => team.owner.Subscription);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
Subscription: true,
},
})
.then((user) => user.Subscription);
}
return subscriptionsContainActiveEnterprisePlan(subscriptions);
};
@@ -1,45 +0,0 @@
import { Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentDeleteProps {
reason: string;
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentDelete = ({
reason,
documentName,
assetBaseUrl,
}: TemplateDocumentDeleteProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mb-0 mt-6 text-left text-lg font-semibold">
Your document has been deleted by an admin!
</Text>
<Text className="mx-auto mb-6 mt-1 text-left text-base text-slate-400">
"{documentName}" has been deleted by an admin.
</Text>
<Text className="mx-auto mb-6 mt-1 text-left text-base text-slate-400">
This document can not be recovered, if you would like to dispute the reason for future
documents please contact support.
</Text>
<Text className="mx-auto mt-1 text-left text-base text-slate-400">
The reason provided for deletion is the following:
</Text>
<Text className="mx-auto mb-6 mt-1 text-left text-base italic text-slate-400">
{reason}
</Text>
</Section>
</>
);
};
export default TemplateDocumentDelete;
@@ -1,66 +0,0 @@
import config from '@documenso/tailwind-config';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components';
import {
TemplateDocumentDelete,
type TemplateDocumentDeleteProps,
} from '../template-components/template-document-super-delete';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentDeleteEmailTemplateProps = Partial<TemplateDocumentDeleteProps>;
export const DocumentSuperDeleteEmailTemplate = ({
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
reason = 'Unknown',
}: DocumentDeleteEmailTemplateProps) => {
const previewText = `An admin has deleted your document "${documentName}".`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateDocumentDelete
reason={reason}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentSuperDeleteEmailTemplate;
-14
View File
@@ -16,24 +16,10 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
[UserSecurityAuditLogType.PASSKEY_CREATED]: 'Passkey created',
[UserSecurityAuditLogType.PASSKEY_DELETED]: 'Passkey deleted',
[UserSecurityAuditLogType.PASSKEY_UPDATED]: 'Passkey updated',
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
[UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL]: 'Passkey sign in failed',
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
};
/**
* The duration to wait for a passkey to be verified in MS.
*/
export const PASSKEY_TIMEOUT = 60000;
/**
* The maximum number of passkeys are user can have.
*/
export const MAXIMUM_PASSKEYS = 50;
+1 -1
View File
@@ -13,7 +13,7 @@ export const DATE_FORMATS = [
{
key: 'YYYYMMDD',
label: 'YYYY-MM-DD',
value: 'yyyy-MM-dd',
value: 'YYYY-MM-DD',
},
{
key: 'DDMMYYYY',
-26
View File
@@ -1,26 +0,0 @@
import type { TDocumentAuth } from '../types/document-auth';
import { DocumentAuth } from '../types/document-auth';
type DocumentAuthTypeData = {
key: TDocumentAuth;
value: string;
};
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
[DocumentAuth.ACCOUNT]: {
key: DocumentAuth.ACCOUNT,
value: 'Require account',
},
[DocumentAuth.PASSKEY]: {
key: DocumentAuth.PASSKEY,
value: 'Require passkey',
},
[DocumentAuth.TWO_FACTOR_AUTH]: {
key: DocumentAuth.TWO_FACTOR_AUTH,
value: 'Require 2FA',
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
+1 -2
View File
@@ -1,6 +1,6 @@
import { env } from 'next-runtime-env';
import { APP_BASE_URL, WEBAPP_BASE_URL } from './app';
import { APP_BASE_URL } from './app';
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
@@ -23,7 +23,6 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const;
-25
View File
@@ -1,25 +0,0 @@
/**
* For TRPC useQueries that should not be batched with other queries.
*/
export const SKIP_QUERY_BATCH_META = {
trpc: {
context: {
skipBatch: true,
},
},
};
/**
* For TRPC useQueries and useMutations to adjust the logic on when query invalidation
* should occur.
*
* When used in:
* - useQuery: Will not invalidate the given query when a mutation occurs.
* - useMutation: Will not trigger invalidation on all queries when mutation succeeds.
*
*/
export const DO_NOT_INVALIDATE_QUERY_ON_MUTATION = {
meta: {
doNotInvalidateQueryOnMutation: true,
},
};
+4 -8
View File
@@ -137,16 +137,12 @@ export class AppError extends Error {
}
static parseFromJSONString(jsonString: string): AppError | null {
try {
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
if (!parsed.success) {
return null;
}
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
} catch {
if (!parsed.success) {
return null;
}
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
}
}
-112
View File
@@ -1,7 +1,6 @@
/// <reference types="../types/next-auth.d.ts" />
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { compare } from '@node-rs/bcrypt';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { DateTime } from 'luxon';
import type { AuthOptions, Session, User } from 'next-auth';
import type { JWT } from 'next-auth/jwt';
@@ -13,16 +12,12 @@ import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../errors/app-error';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { getAuthenticatorOptions } from '../utils/authenticator';
import { ErrorCode } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = {
@@ -136,113 +131,6 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
};
},
}),
CredentialsProvider({
id: 'webauthn',
name: 'Keypass',
credentials: {
csrfToken: { label: 'csrfToken', type: 'csrfToken' },
},
async authorize(credentials, req) {
const csrfToken = credentials?.csrfToken;
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
try {
const parsedBodyCredential = JSON.parse(req.body?.credential);
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
const challengeToken = await prisma.anonymousVerificationToken
.delete({
where: {
id: csrfToken,
},
})
.catch(() => null);
if (!challengeToken) {
return null;
}
if (challengeToken.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
},
include: {
User: {
select: {
id: true,
email: true,
name: true,
emailVerified: true,
},
},
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const user = passkey.User;
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
const requestMetadata = extractNextAuthRequestMetadata(req);
if (!verification?.verified) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
},
});
return null;
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
return {
id: Number(user.id),
email: user.email,
name: user.name,
emailVerified: user.emailVerified?.toISOString() ?? null,
} satisfies User;
},
}),
],
callbacks: {
async jwt({ token, user, trigger, account }) {
+18 -8
View File
@@ -1,30 +1,40 @@
import { compare } from '@node-rs/bcrypt';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { ErrorCode } from '../../next-auth/error-codes';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: User;
token: string;
backupCode: string;
password: string;
requestMetadata?: RequestMetadata;
};
export const disableTwoFactorAuthentication = async ({
token,
backupCode,
user,
password,
requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => {
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
if (!isValid) {
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
if (!isValid) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
}
await prisma.$transaction(async (tx) => {
+20 -22
View File
@@ -1,7 +1,7 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
@@ -17,38 +17,25 @@ export const enableTwoFactorAuthentication = async ({
code,
requestMetadata,
}: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (user.twoFactorEnabled) {
throw new AppError('TWO_FACTOR_ALREADY_ENABLED');
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
}
if (!user.twoFactorSecret) {
throw new AppError('TWO_FACTOR_SETUP_REQUIRED');
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
if (!isValidToken) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
let recoveryCodes: string[] = [];
await prisma.$transaction(async (tx) => {
const updatedUser = await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
recoveryCodes = getBackupCodes({ user: updatedUser }) ?? [];
if (recoveryCodes.length === 0) {
throw new AppError('MISSING_BACKUP_CODE');
}
const updatedUser = await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
@@ -57,7 +44,18 @@ export const enableTwoFactorAuthentication = async ({
ipAddress: requestMetadata?.ipAddress,
},
});
return await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
});
const recoveryCodes = getBackupCodes({ user: updatedUser });
return { recoveryCodes };
};
+17
View File
@@ -1,3 +1,4 @@
import { compare } from '@node-rs/bcrypt';
import { base32 } from '@scure/base';
import crypto from 'crypto';
import { createTOTPKeyURI } from 'oslo/otp';
@@ -11,12 +12,14 @@ import { symmetricEncrypt } from '../../universal/crypto';
type SetupTwoFactorAuthenticationOptions = {
user: User;
password: string;
};
const ISSUER = 'Documenso';
export const setupTwoFactorAuthentication = async ({
user,
password,
}: SetupTwoFactorAuthenticationOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
@@ -24,6 +27,20 @@ export const setupTwoFactorAuthentication = async ({
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
}
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const secret = crypto.randomBytes(10);
const backupCodes = Array.from({ length: 10 })
@@ -1,30 +0,0 @@
import type { User } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { getBackupCodes } from './get-backup-code';
import { validateTwoFactorAuthentication } from './validate-2fa';
type ViewBackupCodesOptions = {
user: User;
token: string;
};
export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => {
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
if (!isValid) {
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
}
if (!isValid) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
}
const backupCodes = getBackupCodes({ user });
if (!backupCodes) {
throw new AppError('MISSING_BACKUP_CODE');
}
return backupCodes;
};
@@ -1,76 +0,0 @@
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,
};
};
@@ -1,58 +0,0 @@
import { generateRegistrationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { PASSKEY_TIMEOUT } from '../../constants/auth';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyRegistrationOptions = {
userId: number;
};
export const createPasskeyRegistrationOptions = async ({
userId,
}: CreatePasskeyRegistrationOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
name: true,
email: true,
passkeys: true,
},
});
const { passkeys } = user;
const { rpName, rpId: rpID } = getAuthenticatorOptions();
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: userId.toString(),
userName: user.email,
userDisplayName: user.name ?? undefined,
timeout: PASSKEY_TIMEOUT,
attestationType: 'none',
excludeCredentials: passkeys.map((passkey) => ({
id: passkey.credentialId,
type: 'public-key',
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
transports: passkey.transports as AuthenticatorTransportFuture[],
})),
});
await prisma.verificationToken.create({
data: {
userId,
token: options.challenge,
expires: DateTime.now().plus({ minutes: 2 }).toJSDate(),
identifier: 'PASSKEY_CHALLENGE',
},
});
return options;
};
@@ -1,41 +0,0 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeySigninOptions = {
sessionId: string;
};
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
const { rpId, timeout } = getAuthenticatorOptions();
const options = await generateAuthenticationOptions({
rpID: rpId,
userVerification: 'preferred',
timeout,
});
const { challenge } = options;
await prisma.anonymousVerificationToken.upsert({
where: {
id: sessionId,
},
update: {
token: challenge,
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
createdAt: new Date(),
},
create: {
id: sessionId,
token: challenge,
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
createdAt: new Date(),
},
});
return options;
};
@@ -1,106 +0,0 @@
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { MAXIMUM_PASSKEYS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyOptions = {
userId: number;
passkeyName: string;
verificationResponse: RegistrationResponseJSON;
requestMetadata?: RequestMetadata;
};
export const createPasskey = async ({
userId,
passkeyName,
verificationResponse,
requestMetadata,
}: CreatePasskeyOptions) => {
const { _count } = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
_count: {
select: {
passkeys: true,
},
},
},
});
if (_count.passkeys >= MAXIMUM_PASSKEYS) {
throw new AppError('TOO_MANY_PASSKEYS');
}
const verificationToken = await prisma.verificationToken.findFirst({
where: {
userId,
identifier: 'PASSKEY_CHALLENGE',
},
orderBy: {
createdAt: 'desc',
},
});
if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Challenge token not found');
}
await prisma.verificationToken.deleteMany({
where: {
userId,
identifier: 'PASSKEY_CHALLENGE',
},
});
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
}
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
const verification = await verifyRegistrationResponse({
response: verificationResponse,
expectedChallenge: verificationToken.token,
expectedOrigin,
expectedRPID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Verification failed');
}
const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
await prisma.$transaction(async (tx) => {
await tx.passkey.create({
data: {
userId,
name: passkeyName,
credentialId: Buffer.from(credentialID),
credentialPublicKey: Buffer.from(credentialPublicKey),
counter,
credentialDeviceType,
credentialBackedUp,
transports: verificationResponse.response.transports,
},
});
await tx.userSecurityAuditLog.create({
data: {
userId,
type: UserSecurityAuditLogType.PASSKEY_CREATED,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
});
};
@@ -1,41 +0,0 @@
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
export interface DeletePasskeyOptions {
userId: number;
passkeyId: string;
requestMetadata?: RequestMetadata;
}
export const deletePasskey = async ({
userId,
passkeyId,
requestMetadata,
}: DeletePasskeyOptions) => {
await prisma.passkey.findFirstOrThrow({
where: {
id: passkeyId,
userId,
},
});
await prisma.$transaction(async (tx) => {
await tx.passkey.delete({
where: {
id: passkeyId,
userId,
},
});
await tx.userSecurityAuditLog.create({
data: {
userId,
type: UserSecurityAuditLogType.PASSKEY_DELETED,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
});
};
@@ -1,76 +0,0 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { Passkey } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
export interface FindPasskeysOptions {
userId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Passkey;
direction: 'asc' | 'desc';
nulls?: Prisma.NullsOrder;
};
}
export const findPasskeys = async ({
userId,
term = '',
page = 1,
perPage = 10,
orderBy,
}: FindPasskeysOptions) => {
const orderByColumn = orderBy?.column ?? 'lastUsedAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last';
const whereClause: Prisma.PasskeyWhereInput = {
userId,
};
if (term.length > 0) {
whereClause.name = {
contains: term,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.passkey.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: {
sort: orderByDirection,
nulls: orderByNulls,
},
},
select: {
id: true,
userId: true,
name: true,
createdAt: true,
updatedAt: true,
lastUsedAt: true,
counter: true,
credentialDeviceType: true,
credentialBackedUp: true,
transports: true,
},
}),
prisma.passkey.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
};
@@ -1,51 +0,0 @@
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
export interface UpdateAuthenticatorsOptions {
userId: number;
passkeyId: string;
name: string;
requestMetadata?: RequestMetadata;
}
export const updatePasskey = async ({
userId,
passkeyId,
name,
requestMetadata,
}: UpdateAuthenticatorsOptions) => {
const passkey = await prisma.passkey.findFirstOrThrow({
where: {
id: passkeyId,
userId,
},
});
if (passkey.name === name) {
return;
}
await prisma.$transaction(async (tx) => {
await tx.passkey.update({
where: {
id: passkeyId,
userId,
},
data: {
name,
updatedAt: new Date(),
},
});
await tx.userSecurityAuditLog.create({
data: {
userId,
type: UserSecurityAuditLogType.PASSKEY_UPDATED,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
});
};
@@ -7,7 +7,6 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email';
@@ -15,8 +14,6 @@ import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
};
@@ -74,54 +71,32 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
// Document reauth for completing documents is currently not required.
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
// documentAuth: document.authOptions,
// recipientAuth: recipient.authOptions,
// });
// const isValid = await isRecipientAuthorized({
// type: 'ACTION',
// document: document,
// recipient: recipient,
// userId,
// authOptions,
// });
// if (!isValid) {
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
// actionAuth: derivedRecipientActionAuth || undefined,
},
}),
});
}),
});
const pendingRecipients = await prisma.recipient.count({
@@ -1,64 +1,16 @@
import { prisma } from '@documenso/prisma';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized';
export interface GetDocumentAndSenderByTokenOptions {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
/**
* Whether we enforce the access requirement.
*
* Defaults to true.
*/
requireAccessAuth?: boolean;
}
export interface GetDocumentAndRecipientByTokenOptions {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
/**
* Whether we enforce the access requirement.
*
* Defaults to true.
*/
requireAccessAuth?: boolean;
}
export type GetDocumentByTokenOptions = {
token: string;
};
export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
}
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
});
return result;
};
export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>;
export const getDocumentAndSenderByToken = async ({
token,
userId,
accessAuth,
requireAccessAuth = true,
}: GetDocumentAndSenderByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
@@ -76,40 +28,12 @@ export const getDocumentAndSenderByToken = async ({
User: true,
documentData: true,
documentMeta: true,
Recipient: {
where: {
token,
},
},
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...User } = result.User;
const recipient = result.Recipient[0];
// Sanity check, should not be possible.
if (!recipient) {
throw new Error('Missing recipient');
}
let documentAccessValid = true;
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
recipient,
userId,
authOptions: accessAuth,
});
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
}
return {
...result,
User,
@@ -121,9 +45,6 @@ export const getDocumentAndSenderByToken = async ({
*/
export const getDocumentAndRecipientByToken = async ({
token,
userId,
accessAuth,
requireAccessAuth = true,
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
if (!token) {
throw new Error('Missing token');
@@ -147,29 +68,6 @@ export const getDocumentAndRecipientByToken = async ({
},
});
const recipient = result.Recipient[0];
// Sanity check, should not be possible.
if (!recipient) {
throw new Error('Missing recipient');
}
let documentAccessValid = true;
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
recipient,
userId,
authOptions: accessAuth,
});
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
}
return {
...result,
Recipient: result.Recipient,
@@ -1,32 +0,0 @@
import { prisma } from '@documenso/prisma';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import { getDocumentWhereInput } from './get-document-by-id';
export type GetDocumentWithDetailsByIdOptions = {
id: number;
userId: number;
teamId?: number;
};
export const getDocumentWithDetailsById = async ({
id,
userId,
teamId,
}: GetDocumentWithDetailsByIdOptions): Promise<DocumentWithDetails> => {
const documentWhereInput = await getDocumentWhereInput({
documentId: id,
userId,
teamId,
});
return await prisma.document.findFirstOrThrow({
where: documentWhereInput,
include: {
documentData: true,
documentMeta: true,
Recipient: true,
Field: true,
},
});
};
@@ -1,213 +0,0 @@
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
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 { DocumentAuth } from '../../types/document-auth';
import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn';
import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
type: 'ACCESS' | 'ACTION';
document: Document;
recipient: Recipient;
/**
* The ID of the user who initiated the request.
*/
userId?: number;
/**
* The auth details to check.
*
* Optional because there are scenarios where no auth options are required such as
* using the user ID.
*/
authOptions?: TDocumentAuthMethods;
};
const getUserByEmail = async (email: string) => {
return await prisma.user.findFirst({
where: {
email,
},
select: {
id: true,
},
});
};
/**
* Whether the recipient is authorized to perform the requested operation on a
* document, given the provided auth options.
*
* @returns True if the recipient can perform the requested operation.
*/
export const isRecipientAuthorized = async ({
type,
document,
recipient,
userId,
authOptions,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const authMethod: TDocumentAuth | null =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required.
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
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.
if (!authOptions || authOptions.type !== authMethod || !userId) {
return false;
}
return await match(authOptions)
.with({ type: DocumentAuth.ACCOUNT }, async () => {
const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) {
return false;
}
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();
};
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,
},
});
};
@@ -2,7 +2,7 @@
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -15,9 +15,7 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
@@ -93,10 +91,31 @@ export const sealDocument = async ({
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(doc);
doc.getForm().flatten();
flattenAnnotations(doc);
const form = doc.getForm();
// Remove old signatures
for (const field of form.getFields()) {
if (field instanceof PDFSignature) {
field.acroField.getWidgets().forEach((widget) => {
widget.ensureAP();
try {
widget.getNormalAppearance();
} catch (e) {
const { context } = widget.dict;
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = context.register(xobj);
widget.setNormalAppearance(streamRef);
}
});
}
}
// Flatten the form to stop annotation layers from appearing above documenso fields
form.flatten();
for (const field of fields) {
await insertFieldInPDF(doc, field);

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