diff --git a/.env.example b/.env.example index d188894de..c482c128e 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,10 @@ NEXTAUTH_SECRET="secret" # [[CRYPTO]] # Application Key for symmetric encryption and decryption -# This should be a random string of at least 32 characters +# REQUIRED: This should be a random string of at least 32 characters NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE" +# REQUIRED: This should be a random string of at least 32 characters +NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF" # [[AUTH OPTIONAL]] NEXT_PRIVATE_GOOGLE_CLIENT_ID="" @@ -23,7 +25,7 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/ # [[E2E Tests]] E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" -E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password" +E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" # [[STORAGE]] # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 @@ -72,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= diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index ab21e8828..ffb788c23 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -33,3 +33,4 @@ body: - label: I have explained the use case or scenario for this feature. - label: I have included any relevant technical details or design suggestions. - label: I understand that this is a suggestion and that there is no guarantee of implementation. + - label: I want to work on creating a PR for this issue if approved diff --git a/apps/marketing/content/blog/commodifying-signing.mdx b/apps/marketing/content/blog/commodifying-signing.mdx new file mode 100644 index 000000000..0a9cf4050 --- /dev/null +++ b/apps/marketing/content/blog/commodifying-signing.mdx @@ -0,0 +1,87 @@ +--- +title: Commodifying Signing +description: We are creating signing as a public good and are commoditizing it to make it cheaper and better. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-01-25 +Tags: + - Vision + - Mission + - Open Source +--- + +
+ + +
+ Lighthouses are often used as an example of a public good; As they benefit all maritime users, but no one can be excluded from using them as a navigational aid. Use by one person neither prevents access by other people, nor does it reduce availability to others. +
+
+ +# Commodifying Signing + +> TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better. + +While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means. + +Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly. + +## Is signing already a commodity? + +> In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them. + +That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market? + +- Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume. +- Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to + +To understand why, we need to look at the landscape as it is today: + +- **Commodity**: Signing SaaS +- **Private Goods**: Signing Code Base, Regulatory Know-How +- **Public Goods**: Web Tech, Digital Signature Algorithms and Standards + +What the current players have done is to commodify the listed public goods into commercial products: + +> […]the action and process of transforming goods, services, ideas, nature, personal information, people, or animals into commodities. +> (Let's ignore the end of that list for now and what it says about humanity, yikes) + +While this paradigm brought digital signing to many businesses worldwide, we aim for a different future. To solve signing once and for all, we need to achieve two core points: + +- Making it cheaper so it's profitable for everyone to use +- Making it more accessible so everyone can use it (e.g. regulated industries) and flexible enough (extendable, open). + +To achieve this, we must transform the landscape to look like this: + +- **Commodities**: Enterprise Components, Support, Hosting, Self-Host Licenses +- **Public Goods**: (no longer private): OS (Open Source) Signing Code Base, OS Regulatory Know-How +- **Public Goods**: OS Web Tech, Digital Signature Algorithms and Standards + +## Raising the Bar + +Before creating a commodity, we are raising the bar of what the underlying public good is. Having an open source singing framework you can extend, self-host, and understand makes the resulting solution much more accessible and extendable for everyone. Now for the final feat of making signing cheaper: + +As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I: + +> In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers. + +By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish. + +## Changing the Game + +In this new world, a company needing signing (literally every company) can decide if the ROI (Return on Investment) of building signing themselves is greater than simply paying for the value-added activities they will need anyway. Pricing our offering not on volume but fixed is a nice additional wedge into the market we intend to use here. + +The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital efficiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect. + +We will grow our community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards. + +As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments. + +Best from Hamburg\ +Timur diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index 27b1ae208..1267931d6 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -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 diff --git a/apps/marketing/content/blog/manifest.mdx b/apps/marketing/content/blog/manifest.mdx index 4abd7c068..7f2b7e7cd 100644 --- a/apps/marketing/content/blog/manifest.mdx +++ b/apps/marketing/content/blog/manifest.mdx @@ -7,6 +7,8 @@ authorRole: 'Co-Founder' date: 2023-07-13 tags: - Manifesto + - Open Source + - Vision ---
diff --git a/apps/marketing/public/blog/lighthouse.jpeg b/apps/marketing/public/blog/lighthouse.jpeg new file mode 100644 index 000000000..d71e1eb51 Binary files /dev/null and b/apps/marketing/public/blog/lighthouse.jpeg differ diff --git a/apps/marketing/src/app/(marketing)/[content]/page.tsx b/apps/marketing/src/app/(marketing)/[content]/page.tsx index 5c846e9f2..62c83f400 100644 --- a/apps/marketing/src/app/(marketing)/[content]/page.tsx +++ b/apps/marketing/src/app/(marketing)/[content]/page.tsx @@ -15,7 +15,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) => notFound(); } - return { title: `Documenso - ${document.title}` }; + return { title: document.title }; }; const mdxComponents: MDXComponents = { diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index f1952cc72..866539a92 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -18,7 +18,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => { } return { - title: `Documenso - ${blogPost.title}`, + title: { + absolute: `${blogPost.title} - Documenso Blog`, + }, description: blogPost.description, }; }; diff --git a/apps/marketing/src/app/(marketing)/blog/page.tsx b/apps/marketing/src/app/(marketing)/blog/page.tsx index 747a56ddf..2eac963d1 100644 --- a/apps/marketing/src/app/(marketing)/blog/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from 'next'; + import { allBlogPosts } from 'contentlayer/generated'; +export const metadata: Metadata = { + title: 'Blog', +}; export default function BlogPage() { const blogPosts = allBlogPosts.sort((a, b) => { const dateA = new Date(a.date); diff --git a/apps/marketing/src/app/(marketing)/open/data.ts b/apps/marketing/src/app/(marketing)/open/data.ts index 3b109ea74..a3f314d9f 100644 --- a/apps/marketing/src/app/(marketing)/open/data.ts +++ b/apps/marketing/src/app/(marketing)/open/data.ts @@ -47,6 +47,14 @@ export const TEAM_MEMBERS = [ engagement: 'Full-Time', joinDate: 'October 9th, 2023', }, + { + name: 'Adithya Krishna', + role: 'Software Engineer - II', + salary: '-', + location: 'India', + engagement: 'Full-Time', + joinDate: 'December 1st, 2023', + }, ]; export const FUNDING_RAISED = [ diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index e237919bc..a1fea41e4 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -1,3 +1,5 @@ +import type { Metadata } from 'next'; + import { z } from 'zod'; import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth'; @@ -14,6 +16,10 @@ import { MonthlyTotalUsersChart } from './monthly-total-users-chart'; import { TeamMembers } from './team-members'; import { OpenPageTooltip } from './tooltip'; +export const metadata: Metadata = { + title: 'Open Startup', +}; + export const revalidate = 3600; export const dynamic = 'force-dynamic'; diff --git a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx index a91446408..65a4a55f8 100644 --- a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx +++ b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next'; import Image from 'next/image'; import { z } from 'zod'; @@ -5,7 +6,12 @@ import { z } from 'zod'; import backgroundPattern from '@documenso/assets/images/background-pattern.png'; import { OSSFriendsContainer } from './container'; -import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema'; +import type { TOSSFriendsSchema } from './schema'; +import { ZOSSFriendsSchema } from './schema'; + +export const metadata: Metadata = { + title: 'OSS Friends', +}; export default async function OSSFriendsPage() { const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', { diff --git a/apps/marketing/src/app/(marketing)/page.tsx b/apps/marketing/src/app/(marketing)/page.tsx index 377384701..10918299a 100644 --- a/apps/marketing/src/app/(marketing)/page.tsx +++ b/apps/marketing/src/app/(marketing)/page.tsx @@ -1,4 +1,5 @@ /* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ +import type { Metadata } from 'next'; import { Caveat } from 'next/font/google'; import { cn } from '@documenso/ui/lib/utils'; @@ -10,6 +11,11 @@ import { OpenBuildTemplateBento } from '~/components/(marketing)/open-build-temp import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento'; export const revalidate = 600; +export const metadata: Metadata = { + title: { + absolute: 'Documenso - The Open Source DocuSign Alternative', + }, +}; const fontCaveat = Caveat({ weight: ['500'], diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx index 92043b3b3..e4c7b776a 100644 --- a/apps/marketing/src/app/(marketing)/pricing/page.tsx +++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx @@ -1,5 +1,4 @@ -'use client'; - +import type { Metadata } from 'next'; import Link from 'next/link'; import { @@ -12,6 +11,10 @@ import { Button } from '@documenso/ui/primitives/button'; import { PricingTable } from '~/components/(marketing)/pricing-table'; +export const metadata: Metadata = { + title: 'Pricing', +}; + export type PricingPageProps = { searchParams?: { planId?: string; diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 389528bf8..a1b56257a 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -158,6 +158,7 @@ export const SinglePlayerClient = () => { readStatus: 'OPENED', signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', + role: 'SIGNER', }; const onFileDrop = async (file: File) => { diff --git a/apps/marketing/src/app/(marketing)/singleplayer/page.tsx b/apps/marketing/src/app/(marketing)/singleplayer/page.tsx index a98906476..aafad32a8 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/page.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/page.tsx @@ -1,5 +1,11 @@ +import type { Metadata } from 'next'; + import { SinglePlayerClient } from './client'; +export const metadata: Metadata = { + title: 'Singleplayer', +}; + export const revalidate = 0; // !: This entire file is a hack to get around failed prerendering of diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx index 05206a76f..1745149c6 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -18,7 +18,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); export const metadata = { - title: 'Documenso - The Open Source DocuSign Alternative', + title: { + template: '%s - Documenso', + default: 'Documenso', + }, description: 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', keywords: diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 80c13b275..d4305a04c 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 9910ef111..ecddf1190 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -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 }, () => ( )) .with({ isPending: true, isSigned: true }, () => ( )) .with({ isComplete: true }, () => ( diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index f14321b35..e1d9b64bb 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -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) = Action - - - - Sign - - + {recipient?.role !== RecipientRole.CC && ( + + + {recipient?.role === RecipientRole.VIEWER && ( + <> + + View + + )} + + {recipient?.role === RecipientRole.SIGNER && ( + <> + + Sign + + )} + + {recipient?.role === RecipientRole.APPROVER && ( + <> + + Approve + + )} + + + )} diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index 8bb321377..5780df1dc 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -1,6 +1,8 @@ +import type { Metadata } from 'next'; import Link from 'next/link'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getStats } from '@documenso/lib/server-only/document/get-stats'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; @@ -8,7 +10,6 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; -import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { DocumentStatus } from '~/components/formatter/document-status'; @@ -25,18 +26,22 @@ export type DocumentsPageProps = { }; }; +export const metadata: Metadata = { + title: 'Documents', +}; export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { const { user } = await getRequiredServerComponentSession(); - const stats = await getStats({ - user, - }); - const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : ''; const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; + const stats = await getStats({ + user, + period, + }); + const results = await findDocuments({ userId: user.id, status, @@ -69,7 +74,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage

Documents

- + {[ ExtendedDocumentStatus.INBOX, diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 65b95f9ec..444bd1db0 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -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 (
{ disabled={remaining.documents === 0 || !session?.user.emailVerified} disabledMessage={disabledMessage} onDrop={onFileDrop} + onDropRejected={onFileDropRejected} />
diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 74e4bd685..e226a7e39 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; import { match } from 'ts-pattern'; @@ -17,6 +18,10 @@ import { LocaleDate } from '~/components/formatter/locale-date'; import { BillingPlans } from './billing-plans'; import { BillingPortalButton } from './billing-portal-button'; +export const metadata: Metadata = { + title: 'Billing', +}; + export default async function BillingSettingsPage() { let { user } = await getRequiredServerComponentSession(); diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index cb64fb9cd..60f7da49c 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -1,7 +1,13 @@ +import type { Metadata } from 'next'; + import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { ProfileForm } from '~/components/forms/profile'; +export const metadata: Metadata = { + title: 'Profile', +}; + export default async function ProfileSettingsPage() { const { user } = await getRequiredServerComponentSession(); diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx new file mode 100644 index 000000000..6e183b0c7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx @@ -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 ( +
+

Security activity

+ +

+ View all recent security activity related to your account. +

+ +
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx new file mode 100644 index 000000000..4937749fc --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx @@ -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 ( + , + }, + { + 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: ( + <> + + + + + + + + + + + + + + + + + ), + }} + > + {(table) => } + + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index 9e99b73e8..4e0a40838 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -1,9 +1,19 @@ +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'; import { PasswordForm } from '~/components/forms/password'; +export const metadata: Metadata = { + title: 'Security', +}; + export default async function SecuritySettingsPage() { const { user } = await getRequiredServerComponentSession(); @@ -17,30 +27,76 @@ export default async function SecuritySettingsPage() {
- + {user.identityProvider === 'DOCUMENSO' ? ( +
+ -
+
-

Two Factor Authentication

+ +
+ Two factor authentication -

- Add and manage your two factor security settings to add an extra layer of security to your - account! -

+ + Create one-time passwords that serve as a secondary authentication method for + confirming your identity when requested during the sign-in process. + +
-
-
Two-factor methods
+ + - -
+ {user.twoFactorEnabled && ( + +
+ Recovery codes - {user.twoFactorEnabled && ( -
-
Recovery methods
+ + Two factor authentication recovery codes are used to access your account in the + event that you lose access to your authenticator app. + +
- + + + )}
+ ) : ( + + + Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]} + + + + To update your password, enable two-factor authentication, and manage other security + settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account + settings. + + )} + + +
+ Recent activity + + + View all recent security activity related to your account. + +
+ + +
); } diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 63d6888b1..7930dcd0e 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -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 (
+ {remaining.documents === 0 && ( + + + Document Limit Exceeded! + + You have reached your document limit.{' '} + + Upgrade your account to continue! + + + + )} + + + + + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx new file mode 100644 index 000000000..d3d1c15c3 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; + +export type SigningLayoutProps = { + children: React.ReactNode; +}; + +export default function SigningLayout({ children }: SigningLayoutProps) { + return ( +
+ {children} + + +
+ ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 4b1aed265..a64831804 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -10,13 +10,15 @@ 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'; import { truncateTitle } from '~/helpers/truncate-title'; +import { DocumentPreviewButton } from './document-preview-button'; + export type CompletedSigningPageProps = { params: { token?: string; @@ -92,7 +94,10 @@ export default async function CompletedSigningPage({ ))}

- You have signed + You have + {recipient.role === RecipientRole.SIGNER && ' signed '} + {recipient.role === RecipientRole.VIEWER && ' viewed '} + {recipient.role === RecipientRole.APPROVER && ' approved '} "{truncatedTitle}"

@@ -117,12 +122,20 @@ export default async function CompletedSigningPage({
- + {document.status === DocumentStatus.COMPLETED ? ( + + ) : ( + + )}
{isLoggedIn ? ( diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index f5c94e6ec..7105baafd 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; 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'; @@ -96,73 +96,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
-
-

Sign Document

+
+

+ {recipient.role === RecipientRole.VIEWER && 'View Document'} + {recipient.role === RecipientRole.SIGNER && 'Sign Document'} + {recipient.role === RecipientRole.APPROVER && 'Approve Document'} +

-

- Please review the document before signing. -

+ {recipient.role === RecipientRole.VIEWER ? ( + <> +

+ Please mark as viewed to complete +

-
+
-
-
-
- +
+
+
+ - setFullName(e.target.value.trimStart())} - /> + +
+ + ) : ( + <> +

+ Please review the document before signing. +

-
- +
- - - { - setSignature(value); - }} +
+
+
+ + + setFullName(e.target.value.trimStart())} /> - - +
+ +
+ + + + + { + setSignature(value); + }} + /> + + +
+
+ +
+ + + +
-
- -
- - - -
-
+ + )}
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 004c59329..7e025593c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -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'; @@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp

- {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.

diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index e4d4571fc..a9aedbc3d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -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; onSignatureComplete: () => void | Promise; + 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 = ({
-
Sign Document
+
+ {role === RecipientRole.VIEWER && 'Mark Document as Viewed'} + {role === RecipientRole.SIGNER && 'Sign Document'} + {role === RecipientRole.APPROVER && 'Approve Document'} +
- 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?`}
@@ -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'}
diff --git a/apps/web/src/app/(unauthenticated)/check-email/page.tsx b/apps/web/src/app/(unauthenticated)/check-email/page.tsx index fffbc44c1..94b410a8e 100644 --- a/apps/web/src/app/(unauthenticated)/check-email/page.tsx +++ b/apps/web/src/app/(unauthenticated)/check-email/page.tsx @@ -1,7 +1,12 @@ +import type { Metadata } from 'next'; import Link from 'next/link'; import { Button } from '@documenso/ui/primitives/button'; +export const metadata: Metadata = { + title: 'Forgot password', +}; + export default function ForgotPasswordPage() { return (
diff --git a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx index 20ecddf4d..36c023027 100644 --- a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx +++ b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx @@ -1,7 +1,12 @@ +import type { Metadata } from 'next'; import Link from 'next/link'; import { ForgotPasswordForm } from '~/components/forms/forgot-password'; +export const metadata: Metadata = { + title: 'Forgot Password', +}; + export default function ForgotPasswordPage() { return (
diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx index c4f521363..93cd41ebb 100644 --- a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx +++ b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx @@ -1,7 +1,12 @@ +import type { Metadata } from 'next'; import Link from 'next/link'; import { Button } from '@documenso/ui/primitives/button'; +export const metadata: Metadata = { + title: 'Reset Password', +}; + export default function ResetPasswordPage() { return (
diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 0b0333b65..1332a3f37 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -1,7 +1,14 @@ +import type { Metadata } from 'next'; import Link from 'next/link'; +import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; + import { SignInForm } from '~/components/forms/signin'; +export const metadata: Metadata = { + title: 'Sign In', +}; + export default function SignInPage() { return (
@@ -11,7 +18,7 @@ export default function SignInPage() { Welcome back, we are lucky to have you.

- + {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx index 353716d9b..c6d49f891 100644 --- a/apps/web/src/app/(unauthenticated)/signup/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx @@ -1,8 +1,15 @@ +import type { Metadata } from 'next'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; + import { SignUpForm } from '~/components/forms/signup'; +export const metadata: Metadata = { + title: 'Sign Up', +}; + export default function SignUpPage() { if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { redirect('/signin'); @@ -17,7 +24,7 @@ export default function SignUpPage() { signing is within your grasp.

- +

Already have an account?{' '} diff --git a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx index 04202d19b..30d2baf16 100644 --- a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx +++ b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx @@ -1,9 +1,14 @@ +import type { Metadata } from 'next'; import Link from 'next/link'; import { XCircle } from 'lucide-react'; import { Button } from '@documenso/ui/primitives/button'; +export const metadata: Metadata = { + title: 'Verify Email', +}; + export default function EmailVerificationWithoutTokenPage() { return (

diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ac88469b0..17f92fa2b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -20,7 +20,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); export const metadata = { - title: 'Documenso - The Open Source DocuSign Alternative', + title: { + template: '%s - Documenso', + default: 'Documenso', + }, description: 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', keywords: diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx index d04b3a998..46182c36e 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -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)} /> - - {recipient.email} +
+
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

+
+
); } diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 7429d8ee5..bd7bea2b0 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -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)} /> - {recipient.email} +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

+
))}
diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 93f7fa729..0312a96d2 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -252,7 +252,11 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => ); return THEMES.map((theme) => ( - setTheme(theme.theme)}> + setTheme(theme.theme)} + className="mx-2 first:mt-2 last:mb-2" + > {theme.label} diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index bdae6c511..ba35671e6 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -33,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { return (
5 && 'border-b-border', className, )} diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index 2dcbb9864..f2432c071 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -68,7 +68,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - + Account {isUserAdmin && ( @@ -122,7 +122,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { Themes - + Light @@ -141,7 +141,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - + Star on Github diff --git a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx index 24e47c186..43eab21c5 100644 --- a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx +++ b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { AlertTriangle } from 'lucide-react'; -import { ONE_SECOND } from '@documenso/lib/constants/time'; +import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -65,7 +65,7 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => { if (emailVerificationDialogLastShown) { const lastShownTimestamp = parseInt(emailVerificationDialogLastShown); - if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) { + if (Date.now() - lastShownTimestamp < ONE_DAY) { return; } } diff --git a/apps/web/src/components/(dashboard)/period-selector/types.ts b/apps/web/src/components/(dashboard)/period-selector/types.ts index 2b50f5d6c..8ae1c5fbe 100644 --- a/apps/web/src/components/(dashboard)/period-selector/types.ts +++ b/apps/web/src/components/(dashboard)/period-selector/types.ts @@ -1,4 +1,4 @@ -export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions diff --git a/apps/web/src/components/forms/2fa/authenticator-app.tsx b/apps/web/src/components/forms/2fa/authenticator-app.tsx index 1d164bd22..316272e34 100644 --- a/apps/web/src/components/forms/2fa/authenticator-app.tsx +++ b/apps/web/src/components/forms/2fa/authenticator-app.tsx @@ -19,27 +19,14 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) return ( <> -
-
-

Authenticator app

- -

- Create one-time passwords that serve as a secondary authentication method for confirming - your identity when requested during the sign-in process. -

-
- -
- {isTwoFactorEnabled ? ( - - ) : ( - - )} -
+
+ {isTwoFactorEnabled ? ( + + ) : ( + + )}
-
- @@ -157,7 +158,7 @@ export const DisableAuthenticatorAppDialog = ({ > Disable 2FA -
+ diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 0db1c8b50..7a181c4cc 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -15,6 +15,7 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; @@ -190,15 +191,15 @@ export const EnableAuthenticatorAppDialog = ({ )} /> -
- -
+ ); @@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({ )} /> -
- -
+ )) diff --git a/apps/web/src/components/forms/2fa/recovery-codes.tsx b/apps/web/src/components/forms/2fa/recovery-codes.tsx index 7e8950227..29834c74a 100644 --- a/apps/web/src/components/forms/2fa/recovery-codes.tsx +++ b/apps/web/src/components/forms/2fa/recovery-codes.tsx @@ -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 ( <> -
-
-

Recovery Codes

- -

- Recovery codes are used to access your account in the event that you lose access to your - authenticator app. -

-
- -
- -
-
+ -
- -
+ ); diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx index 0eb491537..03f95ff7f 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/web/src/components/forms/password.tsx @@ -7,6 +7,7 @@ import { z } from 'zod'; import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; +import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -22,18 +23,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZPasswordFormSchema = z .object({ - currentPassword: z - .string() - .min(6, { message: 'Password should contain at least 6 characters' }) - .max(72, { message: 'Password should not contain more than 72 characters' }), - password: z - .string() - .min(6, { message: 'Password should contain at least 6 characters' }) - .max(72, { message: 'Password should not contain more than 72 characters' }), - repeatedPassword: z - .string() - .min(6, { message: 'Password should contain at least 6 characters' }) - .max(72, { message: 'Password should not contain more than 72 characters' }), + currentPassword: ZCurrentPasswordSchema, + password: ZPasswordSchema, + repeatedPassword: ZPasswordSchema, }) .refine((data) => data.password === data.repeatedPassword, { message: 'Passwords do not match', @@ -145,7 +137,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { /> -
+
diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index ed781abe7..7a4bbdb77 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -197,7 +197,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
- { onChange(v ?? '')} /> diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/web/src/components/forms/reset-password.tsx index 354584f6e..03608a27d 100644 --- a/apps/web/src/components/forms/reset-password.tsx +++ b/apps/web/src/components/forms/reset-password.tsx @@ -8,6 +8,7 @@ import { z } from 'zod'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; +import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,8 +24,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZResetPasswordFormSchema = z .object({ - password: z.string().min(6).max(72), - repeatedPassword: z.string().min(6).max(72), + password: ZPasswordSchema, + repeatedPassword: ZPasswordSchema, }) .refine((data) => data.password === data.repeatedPassword, { path: ['repeatedPassword'], diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 4e671a569..b3e4ea019 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -9,9 +9,16 @@ import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; 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, @@ -39,7 +46,7 @@ const LOGIN_REDIRECT_PATH = '/documents'; export const ZSignInFormSchema = z.object({ email: z.string().email().min(1), - password: z.string().min(6).max(72), + password: ZCurrentPasswordSchema, totpCode: z.string().trim().optional(), backupCode: z.string().trim().optional(), }); @@ -48,9 +55,10 @@ export type TSignInFormSchema = z.infer; export type SignInFormProps = { className?: string; + isGoogleSSOEnabled?: boolean; }; -export const SignInForm = ({ className }: SignInFormProps) => { +export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => { const { toast } = useToast(); const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); @@ -109,7 +117,6 @@ export const SignInForm = ({ className }: SignInFormProps) => { const result = await signIn('credentials', { ...credentials, - callbackUrl: LOGIN_REDIRECT_PATH, redirect: false, }); @@ -203,24 +210,29 @@ export const SignInForm = ({ className }: SignInFormProps) => { {isSubmitting ? 'Signing in...' : 'Sign In'} -
-
- Or continue with -
-
+ {isGoogleSSOEnabled && ( + <> +
+
+ Or continue with +
+
- + + + )} + { )} /> )} + + + + + + - -
- - - -
diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index b91b4a9fd..f38ab15d1 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -3,11 +3,13 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; +import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; +import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,23 +25,33 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export const ZSignUpFormSchema = z.object({ - name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), - email: z.string().email().min(1), - password: z - .string() - .min(6, { message: 'Password should contain at least 6 characters' }) - .max(72, { message: 'Password should not contain more than 72 characters' }), - signature: z.string().min(1, { message: 'We need your signature to sign documents' }), -}); +const SIGN_UP_REDIRECT_PATH = '/documents'; + +export const ZSignUpFormSchema = z + .object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + email: z.string().email().min(1), + password: ZPasswordSchema, + signature: z.string().min(1, { message: 'We need your signature to sign documents' }), + }) + .refine( + (data) => { + const { name, email, password } = data; + return !password.includes(name) && !password.includes(email.split('@')[0]); + }, + { + message: 'Password should not be common or based on personal information', + }, + ); export type TSignUpFormSchema = z.infer; export type SignUpFormProps = { className?: string; + isGoogleSSOEnabled?: boolean; }; -export const SignUpForm = ({ className }: SignUpFormProps) => { +export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); @@ -64,7 +76,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { await signIn('credentials', { email, password, - callbackUrl: '/', + callbackUrl: SIGN_UP_REDIRECT_PATH, }); analytics.capture('App: User Sign Up', { @@ -89,6 +101,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { } }; + const onSignUpWithGoogleClick = async () => { + try { + await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH }); + } catch (err) { + toast({ + title: 'An unknown error occurred', + description: + 'We encountered an unknown error while attempting to sign you Up. Please try again later.', + variant: 'destructive', + }); + } + }; + return (
{ onChange(v ?? '')} /> @@ -166,6 +192,28 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { > {isSubmitting ? 'Signing up...' : 'Sign Up'} + + {isGoogleSSOEnabled && ( + <> +
+
+ Or +
+
+ + + + )} ); diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 4039703b8..365b6ec40 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -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, + }, + }); + }, + }, + }); +} diff --git a/docker/Dockerfile b/docker/Dockerfile index ecdd3b91b..4952b0bbd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/compose.yml b/docker/compose.yml index 9d4f0e951..a48702bf9 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -23,7 +23,8 @@ services: - database - inbucket environment: - - DATABASE_URL=postgres://documenso:password@database:5432/documenso + - NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso + - NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso - NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000 - NEXTAUTH_SECRET=my-super-secure-secret - NEXTAUTH_URL=http://localhost:3000 diff --git a/package-lock.json b/package-lock.json index 69825e8d8..9012d3f29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts index 9a07ec3c7..45b6dea03 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -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'); diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx index 216a3183d..b958e9029 100644 --- a/packages/email/template-components/template-document-invite.tsx +++ b/packages/email/template-components/template-document-invite.tsx @@ -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 ( <>
- {inviterName} has invited you to sign + {inviterName} has invited you to {actionVerb.toLowerCase()}
"{documentName}"
- Continue by signing the document. + Continue by {progressiveVerb.toLowerCase()} the document.
@@ -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
diff --git a/packages/email/template-components/template-footer.tsx b/packages/email/template-components/template-footer.tsx index 4a9e2c7cf..34cd4047e 100644 --- a/packages/email/template-components/template-footer.tsx +++ b/packages/email/template-components/template-footer.tsx @@ -10,7 +10,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => { {isDocument && ( This document was sent using{' '} - + Documenso. diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index d6a45d5fc..d3bceb872 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -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 & { 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} /> @@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({ {customBody ? (
{customBody}
) : ( - `${inviterName} has invited you to sign the document "${documentName}".` + `${inviterName} has invited you to ${action} the document "${documentName}".` )} diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts index 8b5a8a528..44993796a 100644 --- a/packages/lib/client-only/recipient-type.ts +++ b/packages/lib/client-only/recipient-type.ts @@ -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'; } diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index 827fcef0a..a19d2bb0d 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -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; diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index a79293b38..1918e2db0 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -1 +1,25 @@ +import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client'; + export const SALT_ROUNDS = 12; + +export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = { + [IdentityProvider.DOCUMENSO]: 'Documenso', + [IdentityProvider.GOOGLE]: 'Google', +}; + +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', +}; diff --git a/packages/lib/constants/crypto.ts b/packages/lib/constants/crypto.ts index d911cd6cf..102e9d5d4 100644 --- a/packages/lib/constants/crypto.ts +++ b/packages/lib/constants/crypto.ts @@ -1 +1,25 @@ 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 (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 === 'CAFEBABE') { + console.warn('*********************************************************************'); + console.warn('*'); + console.warn('*'); + console.warn('Please change the encryption key from the default value of "CAFEBABE"'); + console.warn('*'); + console.warn('*'); + console.warn('*********************************************************************'); +} diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts new file mode 100644 index 000000000..920cf1f32 --- /dev/null +++ b/packages/lib/constants/recipient-roles.ts @@ -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', + }, +}; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 3b9492807..f23295a81 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -9,10 +9,12 @@ import type { GoogleProfile } from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google'; import { prisma } from '@documenso/prisma'; +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 = { @@ -34,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); } @@ -50,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); } @@ -61,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 @@ -93,7 +114,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { }), ], callbacks: { - async jwt({ token, user }) { + async jwt({ token, user, trigger, account }) { const merged = { ...token, ...user, @@ -138,6 +159,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { merged.emailVerified = user.emailVerified?.toISOString() ?? null; } + if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') { + merged.emailVerified = user?.emailVerified + ? new Date(user.emailVerified).toISOString() + : new Date().toISOString(); + + await prisma.user.update({ + where: { + id: Number(merged.id), + }, + data: { + emailVerified: merged.emailVerified, + identityProvider: IdentityProvider.GOOGLE, + }, + }); + } + return { id: merged.id, name: merged.name, @@ -175,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. }; diff --git a/packages/lib/server-only/2fa/disable-2fa.ts b/packages/lib/server-only/2fa/disable-2fa.ts index 5b27d5c9d..dd8a180c9 100644 --- a/packages/lib/server-only/2fa/disable-2fa.ts +++ b/packages/lib/server-only/2fa/disable-2fa.ts @@ -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; diff --git a/packages/lib/server-only/2fa/enable-2fa.ts b/packages/lib/server-only/2fa/enable-2fa.ts index 9f61e52a4..19a2b67c2 100644 --- a/packages/lib/server-only/2fa/enable-2fa.ts +++ b/packages/lib/server-only/2fa/enable-2fa.ts @@ -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 }); diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts index a60b0934b..23f213574 100644 --- a/packages/lib/server-only/2fa/setup-2fa.ts +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { prisma } from '@documenso/prisma'; -import type { User } from '@documenso/prisma/client'; +import { type User } from '@documenso/prisma/client'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { symmetricEncrypt } from '../../universal/crypto'; diff --git a/packages/lib/server-only/crypto/decrypt.ts b/packages/lib/server-only/crypto/decrypt.ts new file mode 100644 index 000000000..7b4db9894 --- /dev/null +++ b/packages/lib/server-only/crypto/decrypt.ts @@ -0,0 +1,33 @@ +import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto'; +import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; + +/** + * Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data. + * + * @param encryptedData The data encrypted with the `encryptSecondaryData` function. + * @returns The decrypted value, or `null` if the data is invalid or expired. + */ +export const decryptSecondaryData = (encryptedData: string): string | null => { + if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) { + throw new Error('Missing encryption key'); + } + + const decryptedBufferValue = symmetricDecrypt({ + key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, + data: encryptedData, + }); + + const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8'); + const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue)); + + if (!result.success) { + return null; + } + + if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) { + return null; + } + + return result.data.data; +}; diff --git a/packages/lib/server-only/crypto/encrypt.ts b/packages/lib/server-only/crypto/encrypt.ts new file mode 100644 index 000000000..83de19cc2 --- /dev/null +++ b/packages/lib/server-only/crypto/encrypt.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto'; +import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; +import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema'; + +export const ZEncryptedDataSchema = z.object({ + data: z.string(), + expiresAt: z.number().optional(), +}); + +export type EncryptDataOptions = { + data: string; + + /** + * When the data should no longer be allowed to be decrypted. + * + * Leave this empty to never expire the data. + */ + expiresAt?: number; +}; + +/** + * Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data. + * + * @returns The encrypted data. + */ +export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => { + if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) { + throw new Error('Missing encryption key'); + } + + const dataToEncrypt: z.infer = { + data, + expiresAt, + }; + + return symmetricEncrypt({ + key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, + data: JSON.stringify(dataToEncrypt), + }); +}; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index def85f2d4..8d367dbe4 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -3,12 +3,14 @@ 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'; import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; +export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; + export type FindDocumentsOptions = { userId: number; term?: string; @@ -19,7 +21,7 @@ export type FindDocumentsOptions = { column: keyof Omit; direction: 'asc' | 'desc'; }; - period?: '' | '7d' | '14d' | '30d'; + period?: PeriodSelectorValue; }; export const findDocuments = async ({ @@ -85,6 +87,9 @@ export const findDocuments = async ({ some: { email: user.email, signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, }, }, deletedAt: null, @@ -107,6 +112,9 @@ export const findDocuments = async ({ some: { email: user.email, signingStatus: SigningStatus.SIGNED, + role: { + not: RecipientRole.CC, + }, }, }, deletedAt: null, diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts index 89b3777ea..62c8a5ca1 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -1,5 +1,5 @@ import { prisma } from '@documenso/prisma'; -import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; export interface GetDocumentAndSenderByTokenOptions { token: string; @@ -58,7 +58,11 @@ export const getDocumentAndRecipientByToken = async ({ }, }, include: { - Recipient: true, + Recipient: { + where: { + token, + }, + }, documentData: true, }, }); diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index a446b0007..6aaa9a596 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,14 +1,31 @@ +import { DateTime } from 'luxon'; + import { prisma } from '@documenso/prisma'; -import type { User } from '@documenso/prisma/client'; +import type { Prisma, User } from '@documenso/prisma/client'; import { SigningStatus } from '@documenso/prisma/client'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import type { PeriodSelectorValue } from './find-documents'; + export type GetStatsInput = { user: User; + period?: PeriodSelectorValue; }; -export const getStats = async ({ user }: GetStatsInput) => { +export const getStats = async ({ user, period }: GetStatsInput) => { + let createdAt: Prisma.DocumentWhereInput['createdAt']; + + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + + createdAt = { + gte: startOfPeriod.toJSDate(), + }; + } + const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([ prisma.document.groupBy({ by: ['status'], @@ -17,6 +34,7 @@ export const getStats = async ({ user }: GetStatsInput) => { }, where: { userId: user.id, + createdAt, deletedAt: null, }, }), @@ -33,6 +51,7 @@ export const getStats = async ({ user }: GetStatsInput) => { signingStatus: SigningStatus.NOT_SIGNED, }, }, + createdAt, deletedAt: null, }, }), @@ -42,6 +61,12 @@ export const getStats = async ({ user }: GetStatsInput) => { _all: true, }, where: { + createdAt, + User: { + email: { + not: user.email, + }, + }, OR: [ { status: ExtendedDocumentStatus.PENDING, diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index da4ffcb58..4c7b66be8 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -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 }), }); diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 5fa4b1a00..b24288c3e 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -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, + }, }, }); diff --git a/packages/lib/server-only/document/search-documents-with-keyword.ts b/packages/lib/server-only/document/search-documents-with-keyword.ts index c4014d37f..8125ae900 100644 --- a/packages/lib/server-only/document/search-documents-with-keyword.ts +++ b/packages/lib/server-only/document/search-documents-with-keyword.ts @@ -1,6 +1,8 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; +import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; + export type SearchDocumentsWithKeywordOptions = { query: string; userId: number; @@ -77,5 +79,12 @@ export const searchDocumentsWithKeyword = async ({ take: limit, }); - return documents; + const maskedDocuments = documents.map((document) => + maskRecipientTokensForDocument({ + document, + user, + }), + ); + + return maskedDocuments; }; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 25dc132ba..82b37852b 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -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 }), }); diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 198f79be1..4917b213d 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -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, }, }), ), diff --git a/packages/lib/server-only/user/find-user-security-audit-logs.ts b/packages/lib/server-only/user/find-user-security-audit-logs.ts new file mode 100644 index 000000000..0d6b5c8d5 --- /dev/null +++ b/packages/lib/server-only/user/find-user-security-audit-logs.ts @@ -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; + 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; +}; diff --git a/packages/lib/server-only/user/reset-password.ts b/packages/lib/server-only/user/reset-password.ts index 2233894d8..39aac5d28 100644 --- a/packages/lib/server-only/user/reset-password.ts +++ b/packages/lib/server-only/user/reset-password.ts @@ -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 }); diff --git a/packages/lib/server-only/user/update-password.ts b/packages/lib/server-only/user/update-password.ts index b7579cd35..2621fe8e3 100644 --- a/packages/lib/server-only/user/update-password.ts +++ b/packages/lib/server-only/user/update-password.ts @@ -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, + }, + }); + }); }; diff --git a/packages/lib/server-only/user/update-profile.ts b/packages/lib/server-only/user/update-profile.ts index a28fd21c5..a99caff99 100644 --- a/packages/lib/server-only/user/update-profile.ts +++ b/packages/lib/server-only/user/update-profile.ts @@ -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, + }, + }); + }); }; diff --git a/packages/lib/types/search-params.ts b/packages/lib/types/search-params.ts new file mode 100644 index 000000000..ff3fdc4e2 --- /dev/null +++ b/packages/lib/types/search-params.ts @@ -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; diff --git a/packages/lib/universal/extract-request-metadata.ts b/packages/lib/universal/extract-request-metadata.ts new file mode 100644 index 000000000..5549e5de7 --- /dev/null +++ b/packages/lib/universal/extract-request-metadata.ts @@ -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, +): 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, + }; +}; diff --git a/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql b/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql new file mode 100644 index 000000000..441132300 --- /dev/null +++ b/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql @@ -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'; diff --git a/packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql b/packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql new file mode 100644 index 000000000..491012380 --- /dev/null +++ b/packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql @@ -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; diff --git a/packages/prisma/migrations/20240131132916_verify_paid_users/migration.sql b/packages/prisma/migrations/20240131132916_verify_paid_users/migration.sql new file mode 100644 index 000000000..993f103f0 --- /dev/null +++ b/packages/prisma/migrations/20240131132916_verify_paid_users/migration.sql @@ -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 diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index e1549e072..87d29d6b2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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) } @@ -183,20 +209,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[] @@ -280,10 +314,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]) } diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index f342c25fb..49826d7ad 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -1,9 +1,25 @@ import { z } from 'zod'; +export const ZCurrentPasswordSchema = z + .string() + .min(6, { message: 'Must be at least 6 characters in length' }) + .max(72); + +export const ZPasswordSchema = z + .string() + .regex(new RegExp('.*[A-Z].*'), { message: 'One uppercase character' }) + .regex(new RegExp('.*[a-z].*'), { message: 'One lowercase character' }) + .regex(new RegExp('.*\\d.*'), { message: 'One number' }) + .regex(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), { + message: 'One special character is required', + }) + .min(8, { message: 'Must be at least 8 characters in length' }) + .max(72, { message: 'Cannot be more than 72 characters in length' }); + export const ZSignUpMutationSchema = z.object({ name: z.string().min(1), email: z.string().email(), - password: z.string().min(6), + password: ZPasswordSchema, signature: z.string().min(1, { message: 'A signature is required.' }), }); diff --git a/packages/trpc/server/context.ts b/packages/trpc/server/context.ts index e1973f08b..7136afd70 100644 --- a/packages/trpc/server/context.ts +++ b/packages/trpc/server/context.ts @@ -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, }; }; diff --git a/packages/trpc/server/crypto/router.ts b/packages/trpc/server/crypto/router.ts new file mode 100644 index 000000000..db9616436 --- /dev/null +++ b/packages/trpc/server/crypto/router.ts @@ -0,0 +1,17 @@ +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; + +import { procedure, router } from '../trpc'; +import { ZEncryptSecondaryDataMutationSchema } from './schema'; + +export const cryptoRouter = router({ + encryptSecondaryData: procedure + .input(ZEncryptSecondaryDataMutationSchema) + .mutation(({ input }) => { + try { + return encryptSecondaryData(input); + } catch { + // Never leak errors for crypto. + throw new Error('Failed to encrypt data'); + } + }), +}); diff --git a/packages/trpc/server/crypto/schema.ts b/packages/trpc/server/crypto/schema.ts new file mode 100644 index 000000000..ee4b49d53 --- /dev/null +++ b/packages/trpc/server/crypto/schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const ZEncryptSecondaryDataMutationSchema = z.object({ + data: z.string(), + expiresAt: z.number().optional(), +}); + +export const ZDecryptDataMutationSchema = z.object({ + data: z.string(), +}); + +export type TEncryptSecondaryDataMutationSchema = z.infer< + typeof ZEncryptSecondaryDataMutationSchema +>; +export type TDecryptDataMutationSchema = z.infer; diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index c4389bdfb..5d8c23c27 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -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), }), ), }); diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 2cadfd574..cf0cd9029 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -2,16 +2,19 @@ import { TRPCError } from '@trpc/server'; import { deleteStripeCustomer } from '@documenso/ee/server-only/stripe/delete-customer'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; +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, @@ -20,6 +23,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; @@ -43,6 +62,7 @@ export const profileRouter = router({ userId: ctx.user.id, name, signature, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -65,6 +85,7 @@ export const profileRouter = router({ userId: ctx.user.id, password, currentPassword, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { let message = @@ -93,13 +114,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.'; diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index ef9ca2a14..522b13552 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -1,5 +1,12 @@ 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), }); @@ -10,8 +17,8 @@ export const ZUpdateProfileMutationSchema = z.object({ }); export const ZUpdatePasswordMutationSchema = z.object({ - currentPassword: z.string().min(6), - password: z.string().min(6), + currentPassword: ZCurrentPasswordSchema, + password: ZPasswordSchema, }); export const ZForgotPasswordFormSchema = z.object({ @@ -19,7 +26,7 @@ export const ZForgotPasswordFormSchema = z.object({ }); export const ZResetPasswordFormSchema = z.object({ - password: z.string().min(6), + password: ZPasswordSchema, token: z.string().min(1), }); @@ -27,6 +34,7 @@ export const ZConfirmEmailMutationSchema = z.object({ email: z.string().email().min(1), }); +export type TFindUserSecurityAuditLogsSchema = z.infer; export type TRetrieveUserByIdQuerySchema = z.infer; export type TUpdateProfileMutationSchema = z.infer; export type TUpdatePasswordMutationSchema = z.infer; diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 09097895c..1ada3d0d3 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -25,6 +25,7 @@ export const recipientRouter = router({ id: signer.nativeId, email: signer.email, name: signer.name, + role: signer.role, })), }); } catch (err) { diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 8920e7672..a6b4e0d11 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -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), }), ), }) diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 77d18e06d..3ed2a0d05 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -1,5 +1,6 @@ import { adminRouter } from './admin-router/router'; import { authRouter } from './auth-router/router'; +import { cryptoRouter } from './crypto/router'; import { documentRouter } from './document-router/router'; import { fieldRouter } from './field-router/router'; import { profileRouter } from './profile-router/router'; @@ -12,6 +13,7 @@ import { twoFactorAuthenticationRouter } from './two-factor-authentication-route export const appRouter = router({ auth: authRouter, + crypto: cryptoRouter, profile: profileRouter, document: documentRouter, field: fieldRouter, diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index e18f4cb4a..28e919e92 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -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, diff --git a/packages/trpc/server/two-factor-authentication-router/router.ts b/packages/trpc/server/two-factor-authentication-router/router.ts index a10f7a543..36fe93a60 100644 --- a/packages/trpc/server/two-factor-authentication-router/router.ts +++ b/packages/trpc/server/two-factor-authentication-router/router.ts @@ -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); diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index badc05931..d7fc44ef7 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -8,6 +8,7 @@ declare namespace NodeJS { NEXT_PRIVATE_DATABASE_URL: string; NEXT_PRIVATE_ENCRYPTION_KEY: string; + NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; diff --git a/packages/ui/components/document/document-dialog.tsx b/packages/ui/components/document/document-dialog.tsx index 6099fecff..2693638fb 100644 --- a/packages/ui/components/document/document-dialog.tsx +++ b/packages/ui/components/document/document-dialog.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { X } from 'lucide-react'; -import { DocumentData } from '@documenso/prisma/client'; +import type { DocumentData } from '@documenso/prisma/client'; import { cn } from '../../lib/utils'; import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog'; diff --git a/packages/ui/primitives/alert.tsx b/packages/ui/primitives/alert.tsx index 5409152b7..092fbb2b4 100644 --- a/packages/ui/primitives/alert.tsx +++ b/packages/ui/primitives/alert.tsx @@ -1,22 +1,33 @@ import * as React from 'react'; -import { 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', }, }, ); @@ -24,19 +35,20 @@ const alertVariants = cva( const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps ->(({ className, variant, ...props }, ref) => ( -
+>(({ className, variant, padding, ...props }, ref) => ( +
)); Alert.displayName = 'Alert'; const AlertTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
), ); @@ -46,7 +58,7 @@ const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); AlertDescription.displayName = 'AlertDescription'; diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index cbc306c66..65f88fc4e 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -35,7 +35,7 @@ const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) {children} @@ -92,7 +92,7 @@ const CommandGroup = React.forwardRef< = (_table: TTable) => React.ReactNode; export interface DataTableProps { columns: ColumnDef[]; + columnVisibility?: VisibilityState; data: TData[]; perPage?: number; currentPage?: number; totalPages?: number; onPaginationChange?: (_page: number, _perPage: number) => void; + onClearFilters?: () => void; + hasFilters?: boolean; children?: DataTableChildren; + skeleton?: { + enable: boolean; + rows: number; + component?: React.ReactNode; + }; + error?: { + enable: boolean; + component?: React.ReactNode; + }; } export function DataTable({ columns, + columnVisibility, data, + error, perPage, currentPage, totalPages, + skeleton, + hasFilters, + onClearFilters, onPaginationChange, children, }: DataTableProps) { @@ -67,6 +84,7 @@ export function DataTable({ getCoreRowModel: getCoreRowModel(), state: { pagination: manualPagination ? pagination : undefined, + columnVisibility, }, manualPagination, pageCount: totalPages, @@ -103,10 +121,31 @@ export function DataTable({ ))} )) + ) : error?.enable ? ( + + {error.component ?? ( + + Something went wrong. + + )} + + ) : skeleton?.enable ? ( + Array.from({ length: skeleton.rows }).map((_, i) => ( + {skeleton.component ?? } + )) ) : ( - - No results. + +

No results found

+ + {hasFilters && onClearFilters !== undefined && ( + + )}
)} diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index 21337956d..6caf6d040 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -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; + onDropRejected?: () => void | Promise; 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 = ({

- {disabled ? disabledMessage : 'Drag & drop your document here.'} + {disabled ? disabledMessage : 'Drag & drop your PDF here.'}

diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index a8ae9f0e3..74764df80 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -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 = { + CC: [], + VIEWER: [], + SIGNER: [], + APPROVER: [], + }; + + recipients.forEach((recipient) => { + recipientsByRole[recipient.role].push(recipient); + }); + + return recipientsByRole; + }, [recipients]); + return ( <> +
{selectedField && ( @@ -352,72 +375,94 @@ export const AddFieldsFormPartial = ({ + No recipient matching this description was found. - - {recipients.map((recipient, index) => ( - { - setSelectedSigner(recipient); - setShowRecipientsSelector(false); - }} - > - {recipient.sendStatus !== SendStatus.SENT ? ( - - ) : ( - - - - - - This document has already been sent to this recipient. You can no - longer edit this recipient. - - - )} + {Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => ( + +
+ { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName + } +
- {recipient.name && ( + {recipients.length === 0 && ( +
+ No recipients with this role +
+ )} + + {recipients.map((recipient) => ( + { + setSelectedSigner(recipient); + setShowRecipientsSelector(false); + }} + > - {recipient.name} ({recipient.email}) - - )} + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} - {!recipient.name && ( - - {recipient.email} + {!recipient.name && ( + {recipient.email} + )} - )} - - ))} -
+ +
+ {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + + This document has already been sent to this recipient. You can no + longer edit this recipient. + + + )} +
+
+ ))} +
+ ))}
)}
-
+
-
+
diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 71be1c069..26aedcae7 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -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 = { + SIGNER: , + APPROVER: , + CC: , + VIEWER: , +}; + 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 = ({ />
+ {fields.map((field, index) => ( + + ))} + {signers.map((signer, index) => (
+
+ ( + + )} + /> +
+