diff --git a/.env.example b/.env.example index d188894de..9250ab9cf 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="" 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/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml index 058a025e7..de2983b67 100644 --- a/.github/ISSUE_TEMPLATE/improvement.yml +++ b/.github/ISSUE_TEMPLATE/improvement.yml @@ -1,35 +1,39 @@ -name: 'General Improvement' -description: Suggest a minor enhancement or improvement for this project +name: 'General Improvement Request' +description: 'Suggest a minor enhancement or improvement for this project' +title: '[Title for your improvement suggestion]' body: - - type: markdown - attributes: - value: Please provide a clear and concise title for your improvement suggestion - type: textarea attributes: - label: Improvement Description - description: Describe the improvement you are suggesting in detail. Explain what specific aspect of the project it addresses or enhances. + label: 'Describe the improvement you are suggesting in detail' + description: 'Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.' + validations: + required: true - type: textarea + id: description attributes: - label: Rationale - description: Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change. - - type: textarea + label: 'Additional Information & Alternatives (optional)' + description: 'Are there any additional context or information that might be relevant to the improvement suggestion.' + validations: + required: false + - type: dropdown + id: assignee attributes: - label: Proposed Solution - description: If you have a suggestion for how this improvement could be implemented, describe it here. Include any technical details, design suggestions, or other relevant information. - - type: textarea - attributes: - label: Alternatives (optional) - description: Are there any alternative approaches to achieve the same improvement? Describe other ways to address the issue or enhance the project. - - type: textarea - attributes: - label: Additional Context - description: Add any additional context or information that might be relevant to the improvement suggestion. + label: 'Do you want to work on this improvement?' + multiple: false + options: + - 'No' + - 'Yes' + default: 0 + validations: + required: true - type: checkboxes attributes: - label: Please check the boxes that apply to this improvement suggestion. + label: 'Please check the boxes that apply to this improvement suggestion.' options: - - label: I have searched the existing issues and improvement suggestions to avoid duplication. - - label: I have provided a clear description of the improvement being suggested. - - label: I have explained the rationale behind this improvement. - - 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 have searched the existing issues and improvement suggestions to avoid duplication.' + - label: 'I have provided a clear description of the improvement being suggested.' + - label: 'I have explained the rationale behind this improvement.' + - 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.' + validations: + required: true diff --git a/README.md b/README.md index 62cfeee72..6d2fab334 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty + Documenso Logo

@@ -13,9 +15,9 @@ · Issues · - Roadmap + Upcoming Releases · - Upcoming Launches + Roadmap

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/email-provider-incident-2024-01-10.mdx b/apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx new file mode 100644 index 000000000..0f5279d6e --- /dev/null +++ b/apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx @@ -0,0 +1,28 @@ +--- +title: Jan 10th Email Provider Security Incident +description: On January 10th, 2022, we were notified by our email provider that they had experienced a security incident. +authorName: 'Lucas Smith' +authorImage: '/blog/blog-author-lucas.png' +authorRole: 'Co-Founder' +date: 2024-01-17 +tags: + - Security +--- + +On January 10th, 2024 we were notified by our email provider that a security incident had occurred. This security incident which had started on January 7th led to a bad actor obtaining access to their database which contains ours and other customer’s data. + +We understand that during this security incident the following has been accessed: + +- Email addresses. +- Metadata on emails sent excluding the email body. + +While the incident is unfortunate we are pleased with the remediation and the processes that our email provider has put in place to help avoid this kind of situation in the future. Since the incident, our provider has rectified the issue and has engaged a security company to conduct an exhaustive investigation and to help improve their security posture moving forward. + +We remain steadfast in our commitment to our current email provider, and will not be taking any further action with relation to changing providers. + +We are now working with our legal counsel to ensure that we provide the appropriate notice to all our customers in each jurisdiction. If you have any further questions on this incident please feel free to contact our support team at [support@documenso.com](mailto:support@documenso.com). + +We appreciate your ongoing support in this matter. + +You can read more on the incident on our providers blog post below: +[https://resend.com/blog/incident-report-for-january-10-2024](https://resend.com/blog/incident-report-for-january-10-2024) diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx new file mode 100644 index 000000000..27b1ae208 --- /dev/null +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -0,0 +1,115 @@ +--- +title: Moving from Linear to GitHub & LIVE Roadmap 2.0 +description: We are leaving linear and are going all in on GitHub. Here is how we do it. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-01-10 +Tags: + - GitHub + - Backlog + - Roadmap +--- + +# From Linear to GitHub + +> TLDR; We are leaving Linear and are using only GitHub going forward. We no longer communicate feature timelines, only what we are working on and what's next. + +If you follow us, you know we have been in full-on build mode. We are building, the community is building, it's great. Building is our daily business, so we think a lot about improving our approach to doing it. +Our most recent approach is to reduce the number of tools and platforms we use. Every tool we use + +- Reduces the average time you spend on the tool +- Reduces your focus +- Increases mental load to keep all points of interest in mind + +We thought about where we spend the most time, and hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because: + +- It's where the community contributes, and we are all about community +- It's where we show the world what we are working on + +# The old structure + +So far, we have been using Linear for our Backlog/ Task Management and synced issues we want to showcase or work on with the community via synclinear.com. Not only did we have our development issues there, but since +we have our own resident founding designer, we created a proper design backlog to structure our design workflows. + +# The new structure + +We moved everything to GitHub once we realized our focus was already there. This has a few key benefits: + +- Reducing dilution of attention and time: You can hang out on GitHub without risk of missing much +- Putting different aspects of Documenso close to each other: Development, Design, Community +- Keep long-term, niche, and very abstract issues out of the main repo so we don't get desensitized by large issue numbers + +To achieve this, we created a few GitHub repositories to host issues, with the main repository remaining the central point of interest, especially for the community. + +## 1. Main Repository - Day to day Issues and the shorter-term roadmap (LIVE Roadmap 2.0) + +> [github.com/documenso/documenso](https://github.com/documenso/documenso) + +Apart from the source code of the Documenso app and website, the main repo houses issues raised by the community and issues where we invite the community to participate. +With the overhauling of our issue management, we are also updating our progress communication. While the software and product development process is highly complex, +we try to give as much insight into what we do as possible. To that end, we went through 3 phases, three being what we do now. + +1. **One extensive roadmap**: Initially we had one roadmap and were (very) slowly checking off boxes there (via a "Roadmap" milestone). While this is easy, it's also pretty imprecise and not practical as the project grows +2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter; a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel, + like working with the community and tuning things you go. Hitting time targets is tricky because there may be better things to do than sticking to that time target. This is always much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in a vacuum. +3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the live roadmap [https://documen.so/live](https://documen.so/live). It now shows what we are currently working on and what we plan on doing next. We do not provide + a specific timeline anymore since we couldn't even if we wanted to. Of course, we set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible. + +## 2. Public Backlog - The longer-term roadmap + +> [github.com/documenso/backlog](https://github.com/documenso/backlog) + +The public backlog houses everything we want to build eventually. We do not provide a specific timeline of when that might happen. If we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. This helps us gauge interest in specific features. +**Issues in the public backlog are not** available to be worked on. For issues to work on, please check the main repository issues. The issues found here are scoped broader since they are not meant for immediate execution but rather give a sense of where Documenso is going and what we consider part of our domain. + +## 3. Internal Backlog + +> github.com/documenso/backlog-internal + +
+ + +
+ Our internal Kanban for development +
+
+ +This serves as the direct replacement for our Linear backlog. Here, we manage issues that are either too small or short-term for inclusion in the long-term roadmap, yet too specialized or fundamental to be integrated into the main repository. Our development Kanban board is implemented using a GitHub project. + +## 4. Internal Design Backlog + +> github.com/documenso/design-internal + +
+ + +
+ Our internal Kanban for design +
+
+ +This is the design equivalent of the internal backlog. The internal design backlog houses our design projects that include the exploration of new features, detailed UI designs, and improving the platform overall. +It's similar to the Kanban board for the development backlog. + +## 5. Public Design Repository + +> [github.com/documenso/backlog-design](https://github.com/documenso/design) + +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 :) + +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/content/blog/pre-seed.mdx b/apps/marketing/content/blog/pre-seed.mdx index fae0a6c4a..215700355 100644 --- a/apps/marketing/content/blog/pre-seed.mdx +++ b/apps/marketing/content/blog/pre-seed.mdx @@ -1,6 +1,6 @@ --- title: Announcing Pre-Seed and Open Metrics -description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. +description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. authorName: 'Timur Ercan' authorImage: '/blog/blog-author-timur.jpeg' authorRole: 'Co-Founder' diff --git a/apps/marketing/content/blog/shop.mdx b/apps/marketing/content/blog/shop.mdx index fafd98a40..cb5b65554 100644 --- a/apps/marketing/content/blog/shop.mdx +++ b/apps/marketing/content/blog/shop.mdx @@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania) ## Documenso Merch Shop -The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso. +The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
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/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/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index a5dc9e23e..2159b87f2 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; +import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/react'; @@ -29,6 +29,7 @@ export type EditDocumentFormProps = { user: User; document: DocumentWithData; recipients: Recipient[]; + documentMeta: DocumentMeta | null; fields: Field[]; documentData: DocumentData; }; @@ -41,6 +42,7 @@ export const EditDocumentForm = ({ document, recipients, fields, + documentMeta, user: _user, documentData, }: EditDocumentFormProps) => { @@ -56,6 +58,8 @@ export const EditDocumentForm = ({ const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation(); const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation(); + const { mutateAsync: setPasswordForDocument } = + trpc.document.setPasswordForDocument.useMutation(); const documentFlow: Record = { title: { @@ -176,6 +180,13 @@ export const EditDocumentForm = ({ } }; + const onPasswordSubmit = async (password: string) => { + await setPasswordForDocument({ + documentId: document.id, + password, + }); + }; + const currentDocumentFlow = documentFlow[step]; return ( @@ -185,7 +196,13 @@ export const EditDocumentForm = ({ gradient > - + diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 708746af1..bf58ae36a 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -3,10 +3,12 @@ import { redirect } from 'next/navigation'; import { ChevronLeft, Users2 } from 'lucide-react'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -40,7 +42,24 @@ export default async function DocumentPage({ params }: DocumentPageProps) { redirect('/documents'); } - const { documentData } = document; + const { documentData, documentMeta } = document; + + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } const [recipients, fields] = await Promise.all([ getRecipientsForDocument({ @@ -83,6 +102,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { className="mt-8" document={document} user={user} + documentMeta={documentMeta} recipients={recipients} fields={fields} documentData={documentData} @@ -91,7 +111,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) { {document.status === InternalDocumentStatus.COMPLETED && (
- +
)} 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 54a8f6184..9910ef111 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 @@ -6,7 +6,7 @@ import { Download, Edit, Pencil } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +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 type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; @@ -55,28 +55,14 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const documentData = document?.documentData; if (!documentData) { - return; + throw Error('No document available'); } - const documentBytes = await getFile(documentData); - - const blob = new Blob([documentBytes], { - type: 'application/pdf', - }); - - const link = window.document.createElement('a'); - const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); - } catch (error) { + await downloadPDF({ documentData, fileName: row.title }); + } catch (err) { toast({ title: 'Something went wrong', - description: 'An error occurred while trying to download file.', + description: 'An error occurred while downloading your document.', variant: 'destructive', }); } 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 e1282d29f..f14321b35 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 @@ -17,7 +17,7 @@ import { } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +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 type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; @@ -30,6 +30,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { ResendDocumentActionItem } from './_action-items/resend-document'; import { DeleteDocumentDialog } from './delete-document-dialog'; @@ -44,6 +45,7 @@ export type DataTableActionDropdownProps = { export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { const { data: session } = useSession(); + const { toast } = useToast(); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); @@ -63,39 +65,33 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = const isDocumentDeletable = isOwner; const onDownloadClick = async () => { - let document: DocumentWithData | null = null; + try { + let document: DocumentWithData | null = null; - if (!recipient) { - document = await trpcClient.document.getDocumentById.query({ - id: row.id, - }); - } else { - document = await trpcClient.document.getDocumentByToken.query({ - token: recipient.token, + if (!recipient) { + document = await trpcClient.document.getDocumentById.query({ + id: row.id, + }); + } else { + document = await trpcClient.document.getDocumentByToken.query({ + token: recipient.token, + }); + } + + const documentData = document?.documentData; + + if (!documentData) { + return; + } + + await downloadPDF({ documentData, fileName: row.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', }); } - - const documentData = document?.documentData; - - if (!documentData) { - return; - } - - const documentBytes = await getFile(documentData); - - const blob = new Blob([documentBytes], { - type: 'application/pdf', - }); - - const link = window.document.createElement('a'); - const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index f38668fd9..a15d65306 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next'; import Link from 'next/link'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; @@ -25,6 +26,9 @@ export type DocumentsPageProps = { }; }; +export const metadata: Metadata = { + title: 'Documents', +}; export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { const { user } = await getRequiredServerComponentSession(); @@ -88,7 +92,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage {value !== ExtendedDocumentStatus.ALL && ( - + {Math.min(stats[value], 99)} {stats[value] > 99 && '+'} 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/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index 9e99b73e8..854ba66ce 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -1,9 +1,16 @@ +import type { Metadata } from 'next'; + +import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; 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,28 +24,43 @@ 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! -

+

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

-
-
Two-factor methods
+
+
Two-factor methods
- -
+ +
- {user.twoFactorEnabled && ( -
-
Recovery methods
+ {user.twoFactorEnabled && ( +
+
Recovery methods
- + +
+ )} +
+ ) : ( +
+

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

)}
diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx index f4167e42a..d3dacd501 100644 --- a/apps/web/src/app/(dashboard)/templates/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import type { Metadata } from 'next'; + import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; @@ -14,6 +16,10 @@ type TemplatesPageProps = { }; }; +export const metadata: Metadata = { + title: 'Templates', +}; + export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { const { user } = await getRequiredServerComponentSession(); const page = Number(searchParams.page) || 1; diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx new file mode 100644 index 000000000..c0881bd44 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useState } from 'react'; + +import { FileSearch } from 'lucide-react'; + +import type { DocumentData } from '@documenso/prisma/client'; +import DocumentDialog from '@documenso/ui/components/document/document-dialog'; +import type { ButtonProps } from '@documenso/ui/primitives/button'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DocumentPreviewButtonProps = { + className?: string; + documentData: DocumentData; +} & ButtonProps; + +export const DocumentPreviewButton = ({ + className, + documentData, + ...props +}: DocumentPreviewButtonProps) => { + const [showDialog, setShowDialog] = useState(false); + + return ( + <> + + + + + ); +}; 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..3d5814113 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -17,6 +17,8 @@ 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; @@ -117,12 +119,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 4f20a8199..f5c94e6ec 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -49,6 +49,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = return sortFieldsByPosition(fields.filter((field) => !field.inserted)); }, [fields]); + const fieldsValidated = () => { + setValidateUninsertedFields(true); + validateFieldsInserted(fields); + }; + const onFormSubmit = async () => { setValidateUninsertedFields(true); @@ -154,6 +159,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = onSignatureComplete={handleSubmit(onFormSubmit)} document={document} fields={fields} + fieldsValidated={fieldsValidated} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index efd0b266c..004c59329 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation'; import { match } from 'ts-pattern'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; @@ -12,6 +13,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; @@ -66,6 +68,23 @@ export default async function SigningPage({ params: { token } }: SigningPageProp redirect(`/sign/${token}/complete`); } + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id }); if (document.deletedAt) { @@ -101,7 +120,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp gradient > - + 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 faecf5d7e..1e86e99bc 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -15,6 +15,7 @@ export type SignDialogProps = { isSubmitting: boolean; document: Document; fields: Field[]; + fieldsValidated: () => void | Promise; onSignatureComplete: () => void | Promise; }; @@ -22,6 +23,7 @@ export const SignDialog = ({ isSubmitting, document, fields, + fieldsValidated, onSignatureComplete, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); @@ -29,21 +31,21 @@ export const SignDialog = ({ const isComplete = fields.every((field) => field.inserted); return ( - +
-
Sign Document
+
Sign Document
You are about to finish signing "{truncatedTitle}". Are you sure?
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 8429870b0..d04b3a998 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { StackAvatar } from './stack-avatar'; @@ -19,6 +20,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { const { toast } = useToast(); const onRecipientClick = () => { + if (!recipient.token) { + return; + } + void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => { toast({ title: 'Copied to clipboard', @@ -28,19 +33,22 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { }; return ( -
+
- - {recipient.email} - + + {recipient.email}
); } diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 39cd9df0d..0312a96d2 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Loader, Monitor, Moon, Sun } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useTheme } from 'next-themes'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -13,6 +14,7 @@ import { SETTINGS_PAGE_SHORTCUT, TEMPLATES_PAGE_SHORTCUT, } from '@documenso/lib/constants/keyboard-shortcuts'; +import type { Document, Recipient } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { CommandDialog, @@ -65,6 +67,8 @@ export type CommandMenuProps = { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const { setTheme } = useTheme(); + const { data: session } = useSession(); + const router = useRouter(); const [isOpen, setIsOpen] = useState(() => open ?? false); @@ -81,6 +85,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }, ); + const isOwner = useCallback( + (document: Document) => document.userId === session?.user.id, + [session?.user.id], + ); + + const getSigningLink = useCallback( + (recipients: Recipient[]) => + `/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`, + [session?.user.email], + ); + const searchResults = useMemo(() => { if (!searchDocumentsData) { return []; @@ -88,15 +103,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return searchDocumentsData.map((document) => ({ label: document.title, - path: `/documents/${document.id}`, + path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient), value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), })); - }, [searchDocumentsData]); + }, [searchDocumentsData, isOwner, getSigningLink]); const currentPage = pages[pages.length - 1]; - const toggleOpen = (e: KeyboardEvent) => { - e.preventDefault(); + const toggleOpen = () => { setIsOpen((isOpen) => !isOpen); onOpenChange?.(!isOpen); @@ -136,7 +150,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]); - useHotkeys(['ctrl+k', 'meta+k'], toggleOpen); + useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true }); useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); @@ -238,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/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 0ce5c7f3d..7036f4e43 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -112,7 +112,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
- { onChange(v ?? '')} /> diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 4e671a569..038f9fe68 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -48,9 +48,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); @@ -203,24 +204,29 @@ export const SignInForm = ({ className }: SignInFormProps) => { {isSubmitting ? 'Signing in...' : 'Sign In'} -
-
- Or continue with -
-
+ {isGoogleSSOEnabled && ( + <> +
+
+ Or continue with +
+
- + + + )} + ; 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 +68,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 +93,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 (
{ > {isSubmitting ? 'Signing up...' : 'Sign Up'} + + {isGoogleSSOEnabled && ( + <> +
+
+ Or +
+
+ + + + )} ); 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 e3c1139f6..69825e8d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19869,7 +19869,8 @@ "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", - "ts-pattern": "^5.0.5" + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" }, "devDependencies": { "@documenso/tailwind-config": "*", 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/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts new file mode 100644 index 000000000..ec7d0c252 --- /dev/null +++ b/packages/lib/client-only/download-pdf.ts @@ -0,0 +1,29 @@ +import type { DocumentData } from '@documenso/prisma/client'; + +import { getFile } from '../universal/upload/get-file'; + +type DownloadPDFProps = { + documentData: DocumentData; + fileName?: string; +}; + +export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => { + const bytes = await getFile(documentData); + + const blob = new Blob([bytes], { + type: 'application/pdf', + }); + + const link = window.document.createElement('a'); + + const [baseTitle] = fileName?.includes('.pdf') + ? fileName.split('.pdf') + : [fileName ?? 'document']; + + link.href = window.URL.createObjectURL(blob); + link.download = `${baseTitle}_signed.pdf`; + + link.click(); + + window.URL.revokeObjectURL(link.href); +}; diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index a79293b38..837ca3e3a 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -1 +1,12 @@ +import { IdentityProvider } 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, +); diff --git a/packages/lib/constants/crypto.ts b/packages/lib/constants/crypto.ts index d911cd6cf..40d3ef113 100644 --- a/packages/lib/constants/crypto.ts +++ b/packages/lib/constants/crypto.ts @@ -1 +1,23 @@ export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY; + +export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY; + +if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys'); +} + +if (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/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 3b9492807..50240174c 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -9,6 +9,7 @@ import type { GoogleProfile } from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google'; import { prisma } from '@documenso/prisma'; +import { IdentityProvider } from '@documenso/prisma/client'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; @@ -93,7 +94,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { }), ], callbacks: { - async jwt({ token, user }) { + async jwt({ token, user, trigger, account }) { const merged = { ...token, ...user, @@ -138,6 +139,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, 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-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 34c33e7cd..b67c6848b 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -4,10 +4,11 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentMetaOptions = { documentId: number; - subject: string; - message: string; - timezone: string; - dateFormat: string; + subject?: string; + message?: string; + timezone?: string; + password?: string; + dateFormat?: string; userId: number; }; @@ -18,6 +19,7 @@ export const upsertDocumentMeta = async ({ dateFormat, documentId, userId, + password, }: CreateDocumentMetaOptions) => { await prisma.document.findFirstOrThrow({ where: { @@ -35,12 +37,14 @@ export const upsertDocumentMeta = async ({ message, dateFormat, timezone, + password, documentId, }, update: { subject, message, dateFormat, + password, timezone, }, }); diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index 5986b4cfe..ddb70b1cb 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -26,6 +26,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI message: true, subject: true, dateFormat: true, + password: true, timezone: true, }, }, diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 18600ebe6..def85f2d4 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -7,6 +7,7 @@ import { 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 FindDocumentsOptions = { userId: number; @@ -173,8 +174,15 @@ export const findDocuments = async ({ }), ]); + const maskedData = data.map((document) => + maskRecipientTokensForDocument({ + document, + user, + }), + ); + return { - data, + data: maskedData, count, currentPage: Math.max(page, 1), perPage, 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..044d9a2dc 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -42,6 +42,11 @@ export const getStats = async ({ user }: GetStatsInput) => { _all: true, }, where: { + User: { + email: { + not: user.email, + }, + }, OR: [ { status: ExtendedDocumentStatus.PENDING, 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/update-document.ts b/packages/lib/server-only/document/update-document.ts index 5ad686860..29ab2c998 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -5,14 +5,16 @@ import type { Prisma } from '@prisma/client'; import { prisma } from '@documenso/prisma'; export type UpdateDocumentOptions = { - documentId: number; data: Prisma.DocumentUpdateInput; + userId: number; + documentId: number; }; -export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => { +export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => { return await prisma.document.update({ where: { id: documentId, + userId, }, data: { ...data, diff --git a/packages/lib/utils/mask-recipient-tokens-for-document.ts b/packages/lib/utils/mask-recipient-tokens-for-document.ts new file mode 100644 index 000000000..ed6e2b13e --- /dev/null +++ b/packages/lib/utils/mask-recipient-tokens-for-document.ts @@ -0,0 +1,38 @@ +import type { User } from '@documenso/prisma/client'; +import type { DocumentWithRecipients } from '@documenso/prisma/types/document-with-recipient'; + +export type MaskRecipientTokensForDocumentOptions = { + document: T; + user?: User; + token?: string; +}; + +export const maskRecipientTokensForDocument = ({ + document, + user, + token, +}: MaskRecipientTokensForDocumentOptions) => { + const maskedRecipients = document.Recipient.map((recipient) => { + if (document.userId === user?.id) { + return recipient; + } + + if (recipient.email === user?.email) { + return recipient; + } + + if (recipient.token === token) { + return recipient; + } + + return { + ...recipient, + token: '', + }; + }); + + return { + ...document, + Recipient: maskedRecipients, + }; +}; diff --git a/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql b/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql new file mode 100644 index 000000000..c2f5150bc --- /dev/null +++ b/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DocumentMeta" ADD COLUMN "password" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f0bfc6fda..e1549e072 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -162,6 +162,7 @@ model DocumentMeta { subject String? message String? timezone String? @db.Text @default("Etc/UTC") + password String? dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a") documentId Int @unique document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index b01c2d434..6409c5bd9 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -18,6 +18,7 @@ export const seedDatabase = async () => { create: { name: 'Example User', email: 'example@documenso.com', + emailVerified: new Date(), password: hashSync('password'), roles: [Role.USER], }, @@ -31,6 +32,7 @@ export const seedDatabase = async () => { create: { name: 'Admin User', email: 'admin@documenso.com', + emailVerified: new Date(), password: hashSync('password'), roles: [Role.USER, Role.ADMIN], }, 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/router.ts b/packages/trpc/server/document-router/router.ts index b4a1b60e3..9dba63797 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; @@ -13,6 +14,7 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document' import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -24,6 +26,7 @@ import { ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema, + ZSetPasswordForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema, } from './schema'; @@ -175,6 +178,38 @@ export const documentRouter = router({ } }), + setPasswordForDocument: authenticatedProcedure + .input(ZSetPasswordForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, password } = input; + + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing encryption key'); + } + + const securePassword = symmetricEncrypt({ + data: password, + key, + }); + + await upsertDocumentMeta({ + documentId, + password: securePassword, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to set the password for this document. Please try again later.', + }); + } + }), + sendDocument: authenticatedProcedure .input(ZSendDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 4559f65f3..c4389bdfb 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -73,6 +73,15 @@ export const ZSendDocumentMutationSchema = z.object({ }), }); +export const ZSetPasswordForDocumentMutationSchema = z.object({ + documentId: z.number(), + password: z.string(), +}); + +export type TSetPasswordForDocumentMutationSchema = z.infer< + typeof ZSetPasswordForDocumentMutationSchema +>; + export const ZResendDocumentMutationSchema = z.object({ documentId: z.number(), recipients: z.array(z.number()).min(1), 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/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/components/document/document-download-button.tsx b/packages/ui/components/document/document-download-button.tsx index a2a35e490..1c4490f4a 100644 --- a/packages/ui/components/document/document-download-button.tsx +++ b/packages/ui/components/document/document-download-button.tsx @@ -5,11 +5,11 @@ import { useState } from 'react'; import { Download } from 'lucide-react'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { DocumentData } from '@documenso/prisma/client'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { Button } from '../../primitives/button'; -import { useToast } from '../../primitives/use-toast'; export type DownloadButtonProps = HTMLAttributes & { disabled?: boolean; @@ -24,43 +24,29 @@ export const DocumentDownloadButton = ({ disabled, ...props }: DownloadButtonProps) => { - const { toast } = useToast(); - const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); const onDownloadClick = async () => { try { setIsLoading(true); if (!documentData) { + setIsLoading(false); return; } - const bytes = await getFile(documentData); - - const blob = new Blob([bytes], { - type: 'application/pdf', + await downloadPDF({ documentData, fileName }).then(() => { + setIsLoading(false); }); - - const link = window.document.createElement('a'); - const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); } catch (err) { - console.error(err); + setIsLoading(false); toast({ - title: 'Error', + title: 'Something went wrong', description: 'An error occurred while downloading your document.', variant: 'destructive', }); - } finally { - setIsLoading(false); } }; diff --git a/packages/ui/package.json b/packages/ui/package.json index ce452091e..34675ba89 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -70,6 +70,7 @@ "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", - "ts-pattern": "^5.0.5" + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" } } 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< onRemove?.()} + onTouchEnd={() => onRemove?.()} > diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx new file mode 100644 index 000000000..571c81716 --- /dev/null +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -0,0 +1,96 @@ +import { useEffect } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { Button } from './button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog'; +import { Form, FormControl, FormField, FormItem, FormMessage } from './form/form'; +import { Input } from './input'; + +const ZPasswordDialogFormSchema = z.object({ + password: z.string(), +}); + +type TPasswordDialogFormSchema = z.infer; + +type PasswordDialogProps = { + open: boolean; + onOpenChange: (_open: boolean) => void; + defaultPassword?: string; + onPasswordSubmit?: (password: string) => void; + isError?: boolean; +}; + +export const PasswordDialog = ({ + open, + onOpenChange, + defaultPassword, + onPasswordSubmit, + isError, +}: PasswordDialogProps) => { + const form = useForm({ + defaultValues: { + password: defaultPassword ?? '', + }, + resolver: zodResolver(ZPasswordDialogFormSchema), + }); + + const onFormSubmit = ({ password }: TPasswordDialogFormSchema) => { + onPasswordSubmit?.(password); + }; + + useEffect(() => { + if (isError) { + form.setError('password', { + type: 'manual', + message: 'The password you have entered is incorrect. Please try again.', + }); + } + }, [form, isError]); + + return ( + + + + Password Required + + + This document is password protected. Please enter the password to view the document. + + + +
+ +
+ ( + + + + + + + + )} + /> + +
+ +
+
+
+ +
+
+ ); +}; diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index 07cdaf1e2..b4e5c10ba 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -3,16 +3,19 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Loader } from 'lucide-react'; -import type { PDFDocumentProxy } from 'pdfjs-dist'; +import { type PDFDocumentProxy, PasswordResponses } from 'pdfjs-dist'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; +import { match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import type { DocumentData } from '@documenso/prisma/client'; +import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { cn } from '../lib/utils'; +import { PasswordDialog } from './document-password-dialog'; import { useToast } from './use-toast'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -43,6 +46,9 @@ const PDFLoader = () => ( export type PDFViewerProps = { className?: string; documentData: DocumentData; + document?: DocumentWithData; + password?: string | null; + onPasswordSubmit?: (password: string) => void | Promise; onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onPageClick?: OnPDFViewerPageClick; [key: string]: unknown; @@ -51,6 +57,8 @@ export type PDFViewerProps = { export const PDFViewer = ({ className, documentData, + password: defaultPassword, + onPasswordSubmit, onDocumentLoad, onPageClick, ...props @@ -59,7 +67,11 @@ export const PDFViewer = ({ const $el = useRef(null); + const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); + const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); + const [isPasswordError, setIsPasswordError] = useState(false); const [documentBytes, setDocumentBytes] = useState(null); const [width, setWidth] = useState(0); @@ -169,57 +181,87 @@ export const PDFViewer = ({
) : ( - onDocumentLoaded(d)} - // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. - // Therefore we add some additional custom error handling. - onSourceError={() => { - setPdfError(true); - }} - externalLinkTarget="_blank" - loading={ -
- {pdfError ? ( + <> + { + // If the document already has a password, we don't need to ask for it again. + if (defaultPassword && reason !== PasswordResponses.INCORRECT_PASSWORD) { + callback(defaultPassword); + return; + } + + setIsPasswordModalOpen(true); + + passwordCallbackRef.current = callback; + + match(reason) + .with(PasswordResponses.NEED_PASSWORD, () => setIsPasswordError(false)) + .with(PasswordResponses.INCORRECT_PASSWORD, () => setIsPasswordError(true)); + }} + onLoadSuccess={(d) => onDocumentLoaded(d)} + // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. + // Therefore we add some additional custom error handling. + onSourceError={() => { + setPdfError(true); + }} + externalLinkTarget="_blank" + loading={ +
+ {pdfError ? ( +
+

Something went wrong while loading the document.

+

Please try again or contact our support.

+
+ ) : ( + + )} +
+ } + error={ +

Something went wrong while loading the document.

Please try again or contact our support.

- ) : ( - - )} -
- } - error={ -
-
-

Something went wrong while loading the document.

-

Please try again or contact our support.

-
- } - > - {Array(numPages) - .fill(null) - .map((_, i) => ( -
- ''} - onClick={(e) => onDocumentPageClick(e, i + 1)} - /> -
- ))} -
+ } + > + {Array(numPages) + .fill(null) + .map((_, i) => ( +
+ ''} + onClick={(e) => onDocumentPageClick(e, i + 1)} + /> +
+ ))} + + + { + passwordCallbackRef.current?.(password); + + setIsPasswordModalOpen(false); + + void onPasswordSubmit?.(password); + }} + isError={isPasswordError} + /> + )}
); diff --git a/packages/ui/primitives/theme-switcher.tsx b/packages/ui/primitives/theme-switcher.tsx index fcc789404..ab7a7d2bd 100644 --- a/packages/ui/primitives/theme-switcher.tsx +++ b/packages/ui/primitives/theme-switcher.tsx @@ -18,7 +18,7 @@ export const ThemeSwitcher = () => { > {isMounted && theme === THEMES_TYPE.LIGHT && ( )} diff --git a/turbo.json b/turbo.json index 3a96c2a07..b78d7c9d0 100644 --- a/turbo.json +++ b/turbo.json @@ -34,6 +34,7 @@ "globalEnv": [ "APP_VERSION", "NEXT_PRIVATE_ENCRYPTION_KEY", + "NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY", "NEXTAUTH_URL", "NEXTAUTH_SECRET", "NEXT_PUBLIC_PROJECT",