Merge branch 'main' of https://github.com/documenso/documenso into feat/sign-redirect

This commit is contained in:
Adithya Krishna
2024-02-05 13:13:16 +05:30
78 changed files with 1441 additions and 363 deletions

View File

@ -74,6 +74,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
# OPTIONAL: The private key to use for DKIM signing.
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
# [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY=

View File

@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog.
While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead.
We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live).
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
Best from Hamburg\
Timur

View File

@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
role: 'SIGNER',
};
const onFileDrop = async (file: File) => {

View File

@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
</DialogDescription>
<SignaturePad
disabled={isSubmitting}
className="aspect-video w-full rounded-md border"
defaultValue={signatureDataUrl || ''}
onChange={setDraftSignatureDataUrl}

View File

@ -45,6 +45,7 @@
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",
"zod": "^3.22.4"
},
@ -53,7 +54,8 @@
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"
"@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39"
},
"overrides": {
"next-auth": {

View File

@ -219,9 +219,9 @@ export const EditDocumentForm = ({
<AddTitleFormPartial
key={recipients.length}
documentFlow={documentFlow.title}
document={document}
recipients={recipients}
fields={fields}
document={document}
onSubmit={onAddTitleFormSubmit}
/>

View File

@ -2,13 +2,13 @@
import Link from 'next/link';
import { Download, Edit, Pencil } from 'lucide-react';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const onDownloadClick = async () => {
try {
@ -68,6 +69,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
}
return match({
isOwner,
isRecipient,
@ -87,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
</Link>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
Sign
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</Button>
))
.with({ isComplete: true }, () => (

View File

@ -5,9 +5,11 @@ import { useState } from 'react';
import Link from 'next/link';
import {
CheckCircle,
Copy,
Download,
Edit,
EyeIcon,
Loader,
MoreHorizontal,
Pencil,
@ -19,7 +21,7 @@ import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -105,12 +107,32 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="mr-2 h-4 w-4" />
Sign
</Link>
</DropdownMenuItem>
{recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
View
</>
)}
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" />
Sign
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</>
)}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
<Link href={`/documents/${row.id}`}>

View File

@ -74,7 +74,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<h1 className="text-4xl font-semibold">Documents</h1>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs defaultValue={status} className="overflow-x-auto">
<Tabs value={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,

View File

@ -10,6 +10,7 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client';
@ -96,6 +97,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
}
};
const onFileDropRejected = () => {
toast({
title: 'Your document failed to upload.',
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000,
variant: 'destructive',
});
};
return (
<div className={cn('relative', className)}>
<DocumentDropzone
@ -103,6 +113,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
/>
<div className="absolute -bottom-6 right-0">

View File

@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
export const metadata: Metadata = {
title: 'Security activity',
};
export default function SettingsSecurityActivityPage() {
return (
<div>
<h3 className="text-2xl font-semibold">Security activity</h3>
<p className="text-muted-foreground mt-2 text-sm">
View all recent security activity related to your account.
</p>
<hr className="my-4" />
<UserSecurityActivityDataTable />
</div>
);
}

View File

@ -0,0 +1,156 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
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 { LocaleDate } from '~/components/formatter/locale-date';
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const UserSecurityActivityDataTable = () => {
const parser = new UAParser();
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.profile.findUserSecurityAuditLogs.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: 'Date',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'Device',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
let output = result.os.name;
if (!output) {
return 'N/A';
}
if (result.os.version) {
output += ` (${result.os.version})`;
}
return output;
},
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
},
]}
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>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination table={table} />}
</DataTable>
);
};

View File

@ -1,7 +1,10 @@
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
@ -26,43 +29,74 @@ export default async function SecuritySettingsPage() {
{user.identityProvider === 'DOCUMENSO' ? (
<div>
<PasswordForm user={user} className="max-w-xl" />
<PasswordForm user={user} />
<hr className="mb-4 mt-8" />
<hr className="border-border/50 mt-6" />
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
<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>
<p className="text-muted-foreground mt-2 text-sm">
Add and manage your two factor security settings to add an extra layer of security to
your account!
</p>
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Two-factor methods</h5>
<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} />
</div>
</Alert>
{user.twoFactorEnabled && (
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Recovery methods</h5>
<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} />
</div>
</Alert>
)}
</div>
) : (
<div>
<h4 className="text-lg font-medium">
<Alert className="p-6" variant="neutral">
<AlertTitle>
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</h4>
<p className="text-muted-foreground mt-2 text-sm">
</AlertTitle>
<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.
</p>
</div>
</AlertDescription>
</Alert>
)}
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>Recent activity</AlertTitle>
<AlertDescription className="mr-2">
View all recent security activity related to your account.
</AlertDescription>
</div>
<Button asChild>
<Link href="/settings/security/activity">View activity</Link>
</Button>
</Alert>
</div>
);
}

View File

@ -2,13 +2,16 @@
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader, Plus } from 'lucide-react';
import { AlertTriangle, Loader, Plus } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } 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 { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -36,6 +39,8 @@ export const TemplatesDataTable = ({
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const { remaining } = useLimits();
const router = useRouter();
const { toast } = useToast();
@ -77,6 +82,19 @@ export const TemplatesDataTable = ({
return (
<div className="relative">
{remaining.documents === 0 && (
<Alert className="mb-4 mt-5">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Document Limit Exceeded!</AlertTitle>
<AlertDescription className="mt-2">
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" href="/settings/billing">
Upgrade your account to continue!
</Link>
</AlertDescription>
</Alert>
)}
<DataTable
columns={[
{
@ -102,7 +120,7 @@ export const TemplatesDataTable = ({
return (
<div className="flex items-center gap-x-4">
<Button
disabled={isRowLoading}
disabled={isRowLoading || remaining.documents === 0}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));

View File

@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/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 { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -94,7 +94,10 @@ export default async function CompletedSigningPage({
))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed
You have
{recipient.role === RecipientRole.SIGNER && ' signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '}
<span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2>

View File

@ -10,7 +10,7 @@ import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
@ -99,73 +99,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
<fieldset
disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<div
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && 'View Document'}
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
</h3>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
{recipient.role === RecipientRole.VIEWER ? (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please mark as viewed to complete
</p>
<hr className="border-border mb-8 mt-4" />
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4" />
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
/>
</div>
</div>
</>
) : (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<div>
<Label htmlFor="Signature">Signature</Label>
<hr className="border-border mb-8 mt-4" />
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</CardContent>
</Card>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
/>
</div>
</div>
</>
)}
</div>
</fieldset>
</form>

View File

@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
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 { DocumentStatus, FieldType, 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';
@ -111,7 +111,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<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 sign this document.
{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>

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import type { Document, Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -17,6 +18,7 @@ export type SignDialogProps = {
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
role: RecipientRole;
};
export const SignDialog = ({
@ -25,6 +27,7 @@ export const SignDialog = ({
fields,
fieldsValidated,
onSignatureComplete,
role,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
@ -45,9 +48,18 @@ export const SignDialog = ({
</DialogTrigger>
<DialogContent>
<div className="text-center">
<div className="text-foreground text-xl font-semibold">Sign Document</div>
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
{role === RecipientRole.SIGNER && 'Sign Document'}
{role === RecipientRole.APPROVER && 'Approve Document'}
</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
You are about to finish signing "{truncatedTitle}". Are you sure?
{role === RecipientRole.VIEWER &&
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.SIGNER &&
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.APPROVER &&
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
</div>
</div>
@ -71,7 +83,9 @@ export const SignDialog = ({
loading={isSubmitting}
onClick={onSignatureComplete}
>
Sign
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
{role === RecipientRole.SIGNER && 'Sign'}
{role === RecipientRole.APPROVER && 'Approve'}
</Button>
</div>
</DialogFooter>

View File

@ -4,6 +4,7 @@ import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
<div>
<div
className="text-muted-foreground text-sm"
title="Click to copy signing link for sending to recipient"
>
<p>{recipient.email} </p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import {
@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div>
))}
</div>

View File

@ -19,27 +19,14 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
return (
<>
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
<div className="flex-1">
<p>Authenticator app</p>
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
Create one-time passwords that serve as a secondary authentication method for confirming
your identity when requested during the sign-in process.
</p>
</div>
<div>
{isTwoFactorEnabled ? (
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
Disable 2FA
</Button>
) : (
<Button onClick={() => setModalState('enable')} size="sm">
Enable 2FA
</Button>
)}
</div>
<div className="flex-shrink-0">
{isTwoFactorEnabled ? (
<Button variant="destructive" onClick={() => setModalState('disable')}>
Disable 2FA
</Button>
) : (
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
)}
</div>
<EnableAuthenticatorAppDialog

View File

@ -11,6 +11,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
@ -145,8 +146,8 @@ export const DisableAuthenticatorAppDialog = ({
/>
</fieldset>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
@ -157,7 +158,7 @@ export const DisableAuthenticatorAppDialog = ({
>
Disable 2FA
</Button>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>

View File

@ -15,6 +15,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
@ -190,15 +191,15 @@ export const EnableAuthenticatorAppDialog = ({
)}
/>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
Continue
</Button>
</div>
</DialogFooter>
</form>
</Form>
);
@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({
)}
/>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
Enable 2FA
</Button>
</div>
</DialogFooter>
</form>
</Form>
))

View File

@ -7,7 +7,6 @@ import { Button } from '@documenso/ui/primitives/button';
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
type RecoveryCodesProps = {
// backupCodes: string[] | null;
isTwoFactorEnabled: boolean;
};
@ -16,22 +15,13 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
return (
<>
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
<div className="flex-1">
<p>Recovery Codes</p>
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
Recovery codes are used to access your account in the event that you lose access to your
authenticator app.
</p>
</div>
<div>
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
View Codes
</Button>
</div>
</div>
<Button
className="flex-shrink-0"
onClick={() => setIsOpen(true)}
disabled={!isTwoFactorEnabled}
>
View Codes
</Button>
<ViewRecoveryCodesDialog
key={isOpen ? 'open' : 'closed'}

View File

@ -11,6 +11,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
@ -119,15 +120,15 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
)}
/>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
Continue
</Button>
</div>
</DialogFooter>
</form>
</Form>
);

View File

@ -137,7 +137,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
/>
</fieldset>
<div className="mt-4">
<div className="ml-auto mt-4">
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Updating password...' : 'Update password'}
</Button>

View File

@ -121,10 +121,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
<FormControl>
<SignaturePad
className="h-44 w-full"
containerClassName={cn(
'rounded-lg border bg-background',
isSubmitting ? 'pointer-events-none opacity-50' : null,
)}
disabled={isSubmitting}
containerClassName={cn('rounded-lg border bg-background')}
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')}
/>

View File

@ -12,7 +12,13 @@ import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
@ -111,7 +117,6 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
const result = await signIn('credentials', {
...credentials,
callbackUrl: LOGIN_REDIRECT_PATH,
redirect: false,
});
@ -270,21 +275,23 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
)}
/>
)}
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onToggleTwoFactorAuthenticationMethodClick}
>
{twoFactorAuthenticationMethod === 'totp'
? 'Use Backup Code'
: 'Use Authenticator'}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</DialogFooter>
</fieldset>
<div className="mt-4 flex items-center justify-between">
<Button
type="button"
variant="ghost"
onClick={onToggleTwoFactorAuthenticationMethodClick}
>
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>

View File

@ -172,6 +172,7 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) =
<FormControl>
<SignaturePad
className="h-36 w-full"
disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>

View File

@ -1,17 +1,65 @@
// import { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
export default NextAuth({
...NEXT_AUTH_OPTIONS,
pages: {
signIn: '/signin',
signOut: '/signout',
error: '/signin',
},
});
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
const { ipAddress, userAgent } = extractNextApiRequestMetadata(req);
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
// res.json({ hello: 'world' });
// }
return await NextAuth(req, res, {
...NEXT_AUTH_OPTIONS,
pages: {
signIn: '/signin',
signOut: '/signout',
error: '/signin',
},
events: {
signIn: async ({ user }) => {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
});
},
signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;
if (isNaN(userId)) {
return;
}
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_OUT,
},
});
},
linkAccount: async ({ user }) => {
const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id;
if (isNaN(userId)) {
return;
}
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
},
});
},
},
});
}

View File

@ -39,6 +39,14 @@ ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
# Encryption keys
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
# Uncomment and use build args to enable remote caching
# ARG TURBO_TEAM
# ENV TURBO_TEAM=$TURBO_TEAM

32
package-lock.json generated
View File

@ -158,6 +158,7 @@
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",
"zod": "^3.22.4"
},
@ -166,7 +167,8 @@
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"
"@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39"
}
},
"apps/web/node_modules/@types/node": {
@ -6756,6 +6758,12 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A=="
},
"node_modules/@types/ua-parser-js": {
"version": "0.7.39",
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz",
"integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==",
"dev": true
},
"node_modules/@types/unist": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
@ -18643,6 +18651,28 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.37",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
"integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"engines": {
"node": "*"
}
},
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",

View File

@ -12,7 +12,7 @@ test.describe.configure({ mode: 'serial' });
const username = 'Test User';
const email = 'test-user@auth-flow.documenso.com';
const password = 'Password123';
const password = 'Password123#';
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup');

View File

@ -1,3 +1,6 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { RecipientRole } from '@documenso/prisma/client';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
documentName: string;
signDocumentLink: string;
assetBaseUrl: string;
role: RecipientRole;
}
export const TemplateDocumentInvite = ({
@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
documentName,
signDocumentLink,
assetBaseUrl,
role,
}: TemplateDocumentInviteProps) => {
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has invited you to sign
{inviterName} has invited you to {actionVerb.toLowerCase()}
<br />"{documentName}"
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by signing the document.
Continue by {progressiveVerb.toLowerCase()} the document.
</Text>
<Section className="mb-6 mt-8 text-center">
@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
Sign Document
{actionVerb} Document
</Button>
</Section>
</Section>

View File

@ -1,3 +1,5 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { RecipientRole } from '@documenso/prisma/client';
import config from '@documenso/tailwind-config';
import {
@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
customBody?: string;
role: RecipientRole;
};
export const DocumentInviteEmailTemplate = ({
@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({
signDocumentLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002',
customBody,
role,
}: DocumentInviteEmailTemplateProps) => {
const previewText = `${inviterName} has invited you to sign ${documentName}`;
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({
documentName={documentName}
signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl}
role={role}
/>
</Section>
</Container>
@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({
{customBody ? (
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
) : (
`${inviterName} has invited you to sign the document "${documentName}".`
`${inviterName} has invited you to ${action} the document "${documentName}".`
)}
</Text>
</Section>

View File

@ -1,10 +1,10 @@
import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => {
if (
recipient.sendStatus === SendStatus.SENT &&
recipient.signingStatus === SigningStatus.SIGNED
recipient.role === RecipientRole.CC ||
(recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
) {
return 'completed';
}

View File

@ -6,3 +6,6 @@ export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
export const APP_BASE_URL = IS_APP_WEB
? process.env.NEXT_PUBLIC_WEBAPP_URL
: process.env.NEXT_PUBLIC_MARKETING_URL;
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;

View File

@ -1,4 +1,4 @@
import { IdentityProvider } from '@documenso/prisma/client';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
export const SALT_ROUNDS = 12;
@ -10,3 +10,16 @@ export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
export const IS_GOOGLE_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
);
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
[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_2FA_FAIL]: 'Sign in 2FA attempt failed',
};

View File

@ -2,14 +2,16 @@ export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
}
if (typeof window === 'undefined') {
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
}
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error(
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
);
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error(
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
);
}
}
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {

View File

@ -0,0 +1,26 @@
import { RecipientRole } from '@documenso/prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION: {
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
} = {
[RecipientRole.APPROVER]: {
actionVerb: 'Approve',
progressiveVerb: 'Approving',
roleName: 'Approver',
},
[RecipientRole.CC]: {
actionVerb: 'CC',
progressiveVerb: 'CC',
roleName: 'CC',
},
[RecipientRole.SIGNER]: {
actionVerb: 'Sign',
progressiveVerb: 'Signing',
roleName: 'Signer',
},
[RecipientRole.VIEWER]: {
actionVerb: 'View',
progressiveVerb: 'Viewing',
roleName: 'Viewer',
},
};

View File

@ -9,11 +9,12 @@ import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma';
import { IdentityProvider } from '@documenso/prisma/client';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { ErrorCode } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = {
@ -35,7 +36,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
},
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
},
authorize: async (credentials, _req) => {
authorize: async (credentials, req) => {
if (!credentials) {
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
}
@ -51,8 +52,18 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}
const isPasswordsSame = await compare(password, user.password);
const requestMetadata = extractNextAuthRequestMetadata(req);
if (!isPasswordsSame) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
},
});
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
}
@ -62,6 +73,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
},
});
throw new Error(
totpCode
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
@ -192,4 +212,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
return true;
},
},
// Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
};

View File

@ -1,21 +1,25 @@
import { compare } from 'bcrypt';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: User;
backupCode: string;
password: string;
requestMetadata?: RequestMetadata;
};
export const disableTwoFactorAuthentication = async ({
backupCode,
user,
password,
requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => {
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
@ -33,15 +37,26 @@ export const disableTwoFactorAuthentication = async ({
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
});
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
});
return true;

View File

@ -1,18 +1,21 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
type EnableTwoFactorAuthenticationOptions = {
user: User;
code: string;
requestMetadata?: RequestMetadata;
};
export const enableTwoFactorAuthentication = async ({
user,
code,
requestMetadata,
}: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
@ -32,13 +35,24 @@ export const enableTwoFactorAuthentication = async ({
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
const updatedUser = await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
type: UserSecurityAuditLogType.AUTH_2FA_ENABLE,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
return await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
});
const recoveryCodes = getBackupCodes({ user: updatedUser });

View File

@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { type User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto';

View File

@ -3,7 +3,7 @@ import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Prisma } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { FindResultSet } from '../../types/find-result-set';
@ -87,6 +87,9 @@ export const findDocuments = async ({
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
deletedAt: null,
@ -109,6 +112,9 @@ export const findDocuments = async ({
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
deletedAt: null,

View File

@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type ResendDocumentOptions = {
documentId: number;
@ -59,6 +61,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
const { email, name } = recipient;
const customEmailTemplate = {
@ -77,8 +83,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
role: recipient.role,
});
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await mailer.sendMail({
to: {
address: email,
@ -90,7 +99,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document',
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});

View File

@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { getFile } from '../../universal/upload/get-file';
@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
role: {
not: RecipientRole.CC,
},
},
});

View File

@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type SendDocumentOptions = {
documentId: number;
@ -47,6 +49,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
const { email, name } = recipient;
const customEmailTemplate = {
@ -55,10 +61,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
'document.name': document.title,
};
if (recipient.sendStatus === SendStatus.SENT) {
return;
}
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
@ -69,8 +71,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
role: recipient.role,
});
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await mailer.sendMail({
to: {
address: email,
@ -82,7 +87,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document',
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});

View File

@ -1,4 +1,5 @@
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions {
id?: number | null;
email: string;
name: string;
role: RecipientRole;
}[];
}
@ -79,13 +81,20 @@ export const setRecipientsForDocument = async ({
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
documentId,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
}),
),

View File

@ -0,0 +1,52 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { UserSecurityAuditLog, UserSecurityAuditLogType } from '@documenso/prisma/client';
export type FindUserSecurityAuditLogsOptions = {
userId: number;
type?: UserSecurityAuditLogType;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Omit<UserSecurityAuditLog, 'id' | 'userId'>;
direction: 'asc' | 'desc';
};
};
export const findUserSecurityAuditLogs = async ({
userId,
type,
page = 1,
perPage = 10,
orderBy,
}: FindUserSecurityAuditLogsOptions) => {
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause = {
userId,
type,
};
const [data, count] = await Promise.all([
prisma.userSecurityAuditLog.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
}),
prisma.userSecurityAuditLog.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
};

View File

@ -1,16 +1,19 @@
import { compare, hash } from 'bcrypt';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { SALT_ROUNDS } from '../../constants/auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { sendResetPassword } from '../auth/send-reset-password';
export type ResetPasswordOptions = {
token: string;
password: string;
requestMetadata?: RequestMetadata;
};
export const resetPassword = async ({ token, password }: ResetPasswordOptions) => {
export const resetPassword = async ({ token, password, requestMetadata }: ResetPasswordOptions) => {
if (!token) {
throw new Error('Invalid token provided. Please try again.');
}
@ -56,6 +59,14 @@ export const resetPassword = async ({ token, password }: ResetPasswordOptions) =
userId: foundToken.userId,
},
}),
prisma.userSecurityAuditLog.create({
data: {
userId: foundToken.userId,
type: UserSecurityAuditLogType.PASSWORD_RESET,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
}),
]);
await sendResetPassword({ userId: foundToken.userId });

View File

@ -1,19 +1,22 @@
import { compare, hash } from 'bcrypt';
import { SALT_ROUNDS } from '@documenso/lib/constants/auth';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { SALT_ROUNDS } from '../../constants/auth';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
export type UpdatePasswordOptions = {
userId: number;
password: string;
currentPassword: string;
requestMetadata?: RequestMetadata;
};
export const updatePassword = async ({
userId,
password,
currentPassword,
requestMetadata,
}: UpdatePasswordOptions) => {
// Existence check
const user = await prisma.user.findFirstOrThrow({
@ -39,14 +42,23 @@ export const updatePassword = async ({
const hashedNewPassword = await hash(password, SALT_ROUNDS);
const updatedUser = await prisma.user.update({
where: {
id: userId,
},
data: {
password: hashedNewPassword,
},
});
return await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId,
type: UserSecurityAuditLogType.PASSWORD_UPDATE,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
return updatedUser;
return await tx.user.update({
where: {
id: userId,
},
data: {
password: hashedNewPassword,
},
});
});
};

View File

@ -1,12 +1,21 @@
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
export type UpdateProfileOptions = {
userId: number;
name: string;
signature: string;
requestMetadata?: RequestMetadata;
};
export const updateProfile = async ({ userId, name, signature }: UpdateProfileOptions) => {
export const updateProfile = async ({
userId,
name,
signature,
requestMetadata,
}: UpdateProfileOptions) => {
// Existence check
await prisma.user.findFirstOrThrow({
where: {
@ -14,15 +23,24 @@ export const updateProfile = async ({ userId, name, signature }: UpdateProfileOp
},
});
const updatedUser = await prisma.user.update({
where: {
id: userId,
},
data: {
name,
signature,
},
});
return await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId,
type: UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
return updatedUser;
return await tx.user.update({
where: {
id: userId,
},
data: {
name,
signature,
},
});
});
};

View File

@ -0,0 +1,20 @@
import { z } from 'zod';
export const ZBaseTableSearchParamsSchema = z.object({
query: z
.string()
.optional()
.catch(() => undefined),
page: z.coerce
.number()
.min(1)
.optional()
.catch(() => undefined),
perPage: z.coerce
.number()
.min(1)
.optional()
.catch(() => undefined),
});
export type TBaseTableSearchParamsSchema = z.infer<typeof ZBaseTableSearchParamsSchema>;

View File

@ -0,0 +1,37 @@
import type { NextApiRequest } from 'next';
import type { RequestInternal } from 'next-auth';
import { z } from 'zod';
const ZIpSchema = z.string().ip();
export type RequestMetadata = {
ipAddress?: string;
userAgent?: string;
};
export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress);
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
const userAgent = req.headers['user-agent'];
return {
ipAddress,
userAgent,
};
};
export const extractNextAuthRequestMetadata = (
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']);
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
const userAgent = req.headers?.['user-agent'];
return {
ipAddress,
userAgent,
};
};

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "RecipientRole" AS ENUM ('CC', 'SIGNER', 'VIEWER', 'APPROVER');
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "role" "RecipientRole" NOT NULL DEFAULT 'SIGNER';

View File

@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN', 'SIGN_IN_FAIL', 'SIGN_IN_2FA_FAIL');
-- CreateTable
CREATE TABLE "UserSecurityAuditLog" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"type" "UserSecurityAuditLogType" NOT NULL,
"userAgent" TEXT,
"ipAddress" TEXT,
CONSTRAINT "UserSecurityAuditLog_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "UserSecurityAuditLog" ADD CONSTRAINT "UserSecurityAuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,6 @@
UPDATE "User"
SET "emailVerified" = NOW()
FROM "Subscription"
WHERE "User"."id" = "Subscription"."userId"
AND "Subscription"."status" = 'ACTIVE'
AND "User"."emailVerified" IS NULL

View File

@ -40,12 +40,38 @@ model User {
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
VerificationToken VerificationToken[]
Template Template[]
VerificationToken VerificationToken[]
Template Template[]
securityAuditLogs UserSecurityAuditLog[]
@@index([email])
}
enum UserSecurityAuditLogType {
ACCOUNT_PROFILE_UPDATE
ACCOUNT_SSO_LINK
AUTH_2FA_DISABLE
AUTH_2FA_ENABLE
PASSWORD_RESET
PASSWORD_UPDATE
SIGN_OUT
SIGN_IN
SIGN_IN_FAIL
SIGN_IN_2FA_FAIL
}
model UserSecurityAuditLog {
id Int @id @default(autoincrement())
userId Int
createdAt DateTime @default(now())
type UserSecurityAuditLogType
userAgent String?
ipAddress String?
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model PasswordResetToken {
id Int @id @default(autoincrement())
token String @unique
@ -161,9 +187,9 @@ model DocumentMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @db.Text @default("Etc/UTC")
password String?
dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a")
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String? @db.Text
@ -184,20 +210,28 @@ enum SigningStatus {
SIGNED
}
enum RecipientRole {
CC
SIGNER
VIEWER
APPROVER
}
model Recipient {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
documentId Int?
templateId Int?
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
token String
expired DateTime?
signedAt DateTime?
role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Field Field[]
Signature Signature[]
@ -281,10 +315,10 @@ model Template {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]
Field Field[]
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]
Field Field[]
@@unique([templateDocumentDataId])
}

View File

@ -1,4 +1,4 @@
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
@ -9,6 +9,7 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
return {
session: null,
user: null,
req,
};
}
@ -16,12 +17,14 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
return {
session: null,
user: null,
req,
};
}
return {
session,
user,
req,
};
};

View File

@ -1,6 +1,6 @@
import { z } from 'zod';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1),
@ -35,6 +35,7 @@ export const ZSetRecipientsForDocumentMutationSchema = z.object({
id: z.number().nullish(),
email: z.string().min(1).email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});

View File

@ -1,15 +1,18 @@
import { TRPCError } from '@trpc/server';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import {
ZConfirmEmailMutationSchema,
ZFindUserSecurityAuditLogsSchema,
ZForgotPasswordFormSchema,
ZResetPasswordFormSchema,
ZRetrieveUserByIdQuerySchema,
@ -18,6 +21,22 @@ import {
} from './schema';
export const profileRouter = router({
findUserSecurityAuditLogs: authenticatedProcedure
.input(ZFindUserSecurityAuditLogsSchema)
.query(async ({ input, ctx }) => {
try {
return await findUserSecurityAuditLogs({
userId: ctx.user.id,
...input,
});
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find user security audit logs. Please try again.',
});
}
}),
getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => {
try {
const { id } = input;
@ -41,6 +60,7 @@ export const profileRouter = router({
userId: ctx.user.id,
name,
signature,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@ -63,6 +83,7 @@ export const profileRouter = router({
userId: ctx.user.id,
password,
currentPassword,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
let message =
@ -91,13 +112,14 @@ export const profileRouter = router({
}
}),
resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input }) => {
resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input, ctx }) => {
try {
const { password, token } = input;
return await resetPassword({
token,
password,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
let message = 'We were unable to reset your password. Please try again.';

View File

@ -2,6 +2,11 @@ import { z } from 'zod';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema';
export const ZFindUserSecurityAuditLogsSchema = z.object({
page: z.number().optional(),
perPage: z.number().optional(),
});
export const ZRetrieveUserByIdQuerySchema = z.object({
id: z.number().min(1),
});
@ -29,6 +34,7 @@ export const ZConfirmEmailMutationSchema = z.object({
email: z.string().email().min(1),
});
export type TFindUserSecurityAuditLogsSchema = z.infer<typeof ZFindUserSecurityAuditLogsSchema>;
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;

View File

@ -25,6 +25,7 @@ export const recipientRouter = router({
id: signer.nativeId,
email: signer.email,
name: signer.name,
role: signer.role,
})),
});
} catch (err) {

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { RecipientRole } from '@documenso/prisma/client';
export const ZAddSignersMutationSchema = z
.object({
documentId: z.number(),
@ -8,6 +10,7 @@ export const ZAddSignersMutationSchema = z
nativeId: z.number().optional(),
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
})

View File

@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
@ -41,6 +42,12 @@ export const templateRouter = router({
try {
const { templateId } = input;
const limits = await getServerLimits({ email: ctx.user.email });
if (limits.remaining.documents === 0) {
throw new Error('You have reached your document limit.');
}
return await createDocumentFromTemplate({
templateId,
userId: ctx.user.id,

View File

@ -6,6 +6,7 @@ import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/en
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, router } from '../trpc';
import {
@ -23,7 +24,10 @@ export const twoFactorAuthenticationRouter = router({
const { password } = input;
return await setupTwoFactorAuthentication({ user, password });
return await setupTwoFactorAuthentication({
user,
password,
});
}),
enable: authenticatedProcedure
@ -34,7 +38,11 @@ export const twoFactorAuthenticationRouter = router({
const { code } = input;
return await enableTwoFactorAuthentication({ user, code });
return await enableTwoFactorAuthentication({
user,
code,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@ -53,7 +61,12 @@ export const twoFactorAuthenticationRouter = router({
const { password, backupCode } = input;
return await disableTwoFactorAuthentication({ user, password, backupCode });
return await disableTwoFactorAuthentication({
user,
password,
backupCode,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);

View File

@ -1,21 +1,33 @@
import * as React from 'react';
import { VariantProps, cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11',
'relative w-full rounded-lg p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&>svg~*]:pl-8',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive',
default:
'bg-green-50 text-green-700 [&_.alert-title]:text-green-800 [&>svg]:text-green-400',
neutral:
'bg-gray-50 dark:bg-neutral-900/20 text-muted-foreground [&_.alert-title]:text-foreground',
secondary: 'bg-blue-50 text-blue-700 [&_.alert-title]:text-blue-800 [&>svg]:text-blue-400',
destructive: 'bg-red-50 text-red-700 [&_.alert-title]:text-red-800 [&>svg]:text-red-400',
warning:
'bg-yellow-50 text-yellow-700 [&_.alert-title]:text-yellow-800 [&>svg]:text-yellow-400',
},
padding: {
tighter: 'p-2',
tight: 'px-4 py-2',
default: 'p-4',
},
},
defaultVariants: {
variant: 'default',
padding: 'default',
},
},
);
@ -23,19 +35,20 @@ const alertVariants = cva(
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
>(({ className, variant, padding, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant, padding }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
<h5 ref={ref} className={cn('alert-title text-base font-medium', className)} {...props} />
),
);
@ -45,7 +58,7 @@ const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
<div ref={ref} className={cn('text-sm', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';

View File

@ -2,36 +2,53 @@
import React, { useMemo } from 'react';
import {
import type {
ColumnDef,
PaginationState,
Table as TTable,
Updater,
flexRender,
getCoreRowModel,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Skeleton } from './skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';
export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNode;
export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
columnVisibility?: VisibilityState;
data: TData[];
perPage?: number;
currentPage?: number;
totalPages?: number;
onPaginationChange?: (_page: number, _perPage: number) => void;
onClearFilters?: () => void;
hasFilters?: boolean;
children?: DataTableChildren<TData>;
skeleton?: {
enable: boolean;
rows: number;
component?: React.ReactNode;
};
error?: {
enable: boolean;
component?: React.ReactNode;
};
}
export function DataTable<TData, TValue>({
columns,
columnVisibility,
data,
error,
perPage,
currentPage,
totalPages,
skeleton,
hasFilters,
onClearFilters,
onPaginationChange,
children,
}: DataTableProps<TData, TValue>) {
@ -67,6 +84,7 @@ export function DataTable<TData, TValue>({
getCoreRowModel: getCoreRowModel(),
state: {
pagination: manualPagination ? pagination : undefined,
columnVisibility,
},
manualPagination,
pageCount: totalPages,
@ -103,10 +121,31 @@ export function DataTable<TData, TValue>({
))}
</TableRow>
))
) : error?.enable ? (
<TableRow>
{error.component ?? (
<TableCell colSpan={columns.length} className="h-32 text-center">
Something went wrong.
</TableCell>
)}
</TableRow>
) : skeleton?.enable ? (
Array.from({ length: skeleton.rows }).map((_, i) => (
<TableRow key={`skeleton-row-${i}`}>{skeleton.component ?? <Skeleton />}</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
<TableCell colSpan={columns.length} className="h-32 text-center">
<p>No results found</p>
{hasFilters && onClearFilters !== undefined && (
<button
onClick={() => onClearFilters()}
className="text-foreground mt-1 text-sm"
>
Clear filters
</button>
)}
</TableCell>
</TableRow>
)}

View File

@ -5,6 +5,7 @@ import { motion } from 'framer-motion';
import { Plus } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { cn } from '../lib/utils';
@ -89,6 +90,7 @@ export type DocumentDropzoneProps = {
disabled?: boolean;
disabledMessage?: string;
onDrop?: (_file: File) => void | Promise<void>;
onDropRejected?: () => void | Promise<void>;
type?: 'document' | 'template';
[key: string]: unknown;
};
@ -96,6 +98,7 @@ export type DocumentDropzoneProps = {
export const DocumentDropzone = ({
className,
onDrop,
onDropRejected,
disabled,
disabledMessage = 'You cannot upload documents at this time.',
type = 'document',
@ -112,7 +115,12 @@ export const DocumentDropzone = ({
void onDrop(acceptedFile);
}
},
maxSize: megabytesToBytes(50),
onDropRejected: () => {
if (onDropRejected) {
void onDropRejected();
}
},
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
});
return (
@ -175,7 +183,7 @@ export const DocumentDropzone = ({
</p>
<p className="text-muted-foreground/80 mt-1 text-sm">
{disabled ? disabledMessage : 'Drag & drop your document here.'}
{disabled ? disabledMessage : 'Drag & drop your PDF here.'}
</p>
</CardContent>
</Card>

View File

@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
@ -10,8 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { FieldType, SendStatus } from '@documenso/prisma/client';
import { cn } from '../../lib/utils';
@ -30,8 +32,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { FieldItem } from './field-item';
import type { DocumentFlowStep } from './types';
import { FRIENDLY_FIELD_TYPE } from './types';
import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
weight: ['500'],
@ -103,6 +104,12 @@ export const AddFieldsFormPartial = ({
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
const isFieldsDisabled =
!selectedSigner ||
hasSelectedSignerBeenSent ||
selectedSigner?.role === RecipientRole.VIEWER ||
selectedSigner?.role === RecipientRole.CC;
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
@ -282,12 +289,28 @@ export const AddFieldsFormPartial = ({
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
}, [recipients]);
const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
APPROVER: [],
};
recipients.forEach((recipient) => {
recipientsByRole[recipient.role].push(recipient);
});
return recipientsByRole;
}, [recipients]);
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && (
@ -352,72 +375,94 @@ export const AddFieldsFormPartial = ({
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
No recipient matching this description was found.
</span>
</CommandEmpty>
<CommandGroup>
{recipients.map((recipient, index) => (
<CommandItem
key={index}
className={cn({
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedSigner}
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="mr-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This document has already been sent to this recipient. You can no
longer edit this recipient.
</TooltipContent>
</Tooltip>
)}
{Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName
}
</div>
{recipient.name && (
{recipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
No recipients with this role
</div>
)}
{recipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('!rounded-2xl px-4 last:mb-1 [&:not(:first-child)]:mt-1', {
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className="truncate"
title={`${recipient.name} (${recipient.email})`}
className={cn('text-foreground/70 truncate', {
'text-foreground': recipient === selectedSigner,
})}
>
{recipient.name} ({recipient.email})
</span>
)}
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span className="truncate" title={recipient.email}>
{recipient.email}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
)}
</CommandItem>
))}
</CommandGroup>
<div className="ml-auto flex items-center justify-center">
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedSigner}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
This document has already been sent to this recipient. You can no
longer edit this recipient.
</TooltipContent>
</Tooltip>
)}
</div>
</CommandItem>
))}
</CommandGroup>
))}
</Command>
</PopoverContent>
</Popover>
)}
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
<fieldset disabled={isFieldsDisabled} className="grid grid-cols-2 gap-x-4 gap-y-8">
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.SIGNATURE)}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
@ -441,7 +486,6 @@ export const AddFieldsFormPartial = ({
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.EMAIL)}
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
@ -464,7 +508,6 @@ export const AddFieldsFormPartial = ({
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.NAME)}
onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
@ -487,7 +530,6 @@ export const AddFieldsFormPartial = ({
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.DATE)}
onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
@ -506,7 +548,7 @@ export const AddFieldsFormPartial = ({
</CardContent>
</Card>
</button>
</div>
</fieldset>
</div>
</div>
</DocumentFlowFormContainerContent>

View File

@ -4,19 +4,20 @@ import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { Button } from '../button';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
@ -28,8 +29,16 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
SIGNER: <PencilLine className="h-4 w-4" />,
APPROVER: <BadgeCheck className="h-4 w-4" />,
CC: <Copy className="h-4 w-4" />,
VIEWER: <Eye className="h-4 w-4" />,
};
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
@ -42,7 +51,7 @@ export const AddSignersFormPartial = ({
documentFlow,
recipients,
document,
fields: _fields,
fields,
onSubmit,
}: AddSignersFormProps) => {
const { toast } = useToast();
@ -66,12 +75,14 @@ export const AddSignersFormPartial = ({
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}))
: [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
},
],
},
@ -103,6 +114,7 @@ export const AddSignersFormPartial = ({
formId: nanoid(12),
name: '',
email: '',
role: RecipientRole.SIGNER,
});
};
@ -136,6 +148,10 @@ export const AddSignersFormPartial = ({
/>
<DocumentFlowFormContainerContent>
<div className="flex w-full flex-col gap-y-4">
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<AnimatePresence>
{signers.map((signer, index) => (
<motion.div
@ -184,6 +200,48 @@ export const AddSignersFormPartial = ({
/>
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`signers.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div>
<button
type="button"

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { RecipientRole } from '.prisma/client';
export const ZAddSignersFormSchema = z
.object({
signers: z.array(
@ -8,6 +10,7 @@ export const ZAddSignersFormSchema = z
nativeId: z.number().optional(),
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
})

View File

@ -38,6 +38,7 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = {
@ -98,6 +99,10 @@ export const AddSubjectFormPartial = ({
/>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="subject">
@ -106,7 +111,6 @@ export const AddSubjectFormPartial = ({
<Input
id="subject"
// placeholder="Subject"
className="bg-background mt-2"
disabled={isSubmitting}
{...register('meta.subject')}

View File

@ -17,6 +17,7 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
export type AddTitleFormProps = {
@ -29,8 +30,8 @@ export type AddTitleFormProps = {
export const AddTitleFormPartial = ({
documentFlow,
recipients: _recipients,
fields: _fields,
recipients,
fields,
document,
onSubmit,
}: AddTitleFormProps) => {
@ -55,6 +56,10 @@ export const AddTitleFormPartial = ({
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<div className="flex flex-col">
<div className="flex flex-col gap-y-4">
<div>

View File

@ -0,0 +1,49 @@
'use client';
import type { Prisma } from '@prisma/client';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { cn } from '../../lib/utils';
import { Card, CardContent } from '../card';
import { FRIENDLY_FIELD_TYPE } from './types';
export type ShowFieldItemProps = {
field: Prisma.FieldGetPayload<null>;
recipients: Prisma.RecipientGetPayload<null>[];
};
export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
const coords = useFieldPageCoords(field);
const signerEmail =
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '';
return createPortal(
<div
className={cn('pointer-events-none absolute z-10 opacity-75')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<Card className={cn('bg-background h-full w-full')}>
<CardContent
className={cn(
'text-muted-foreground/50 flex h-full w-full flex-col items-center justify-center p-2',
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
<p className="text-muted-foreground/50 w-full truncate text-center text-xs">
{signerEmail}
</p>
</CardContent>
</Card>
</div>,
document.body,
);
};

View File

@ -16,6 +16,7 @@ const DPI = 2;
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
onChange?: (_signatureDataUrl: string | null) => void;
containerClassName?: string;
disabled?: boolean;
};
export const SignaturePad = ({
@ -23,6 +24,7 @@ export const SignaturePad = ({
containerClassName,
defaultValue,
onChange,
disabled = false,
...props
}: SignaturePadProps) => {
const $el = useRef<HTMLCanvasElement>(null);
@ -214,7 +216,11 @@ export const SignaturePad = ({
}, [defaultValue]);
return (
<div className={cn('relative block', containerClassName)}>
<div
className={cn('relative block', containerClassName, {
'pointer-events-none opacity-50': disabled,
})}
>
<canvas
ref={$el}
className={cn('relative block dark:invert', className)}

View File

@ -45,6 +45,7 @@
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",