Compare commits

..

16 Commits

Author SHA1 Message Date
github-actions d1528fd63b chore: extract translations 2024-09-11 07:27:20 +00:00
Catalin Pit 85012ae991 Merge branch 'main' into feat/save-data-on-blur 2024-09-11 10:26:39 +03:00
Catalin Pit ff4c9616fc Merge branch 'main' into feat/save-data-on-blur 2024-09-05 10:26:22 +03:00
Catalin Pit fd004f5f24 chore: merge main 2024-08-29 12:37:19 +03:00
github-actions bb222cc0fc chore: extract translations 2024-08-22 05:16:14 +00:00
Catalin Pit f627cbc7db Delete packages/lib/translations/de/common.po 2024-08-22 08:15:24 +03:00
Catalin Pit 67be4fdb45 Delete packages/lib/translations/de/web.po 2024-08-22 08:15:15 +03:00
Catalin Pit c8b7d13b4f Delete packages/lib/translations/de/marketing.po 2024-08-22 08:15:05 +03:00
Catalin Pit 2b4699bb61 Delete packages/lib/translations/de/marketing.js 2024-08-22 08:14:52 +03:00
github-actions 39603fc150 chore: extract translations 2024-08-21 10:59:09 +00:00
Catalin Pit 7f2a8deda3 Merge branch 'main' into feat/save-data-on-blur 2024-08-21 13:58:17 +03:00
Catalin Pit f24a33c182 fix: fix err msg 2024-08-21 13:57:43 +03:00
Catalin Pit 4a8f32a44f fix: remove unnecessary code 2024-08-21 12:56:28 +03:00
Catalin Pit bbf2ed7154 feat: fix code 2024-08-21 12:15:22 +03:00
Catalin Pit 5ce7f4adcc feat: ux improvements 2024-08-21 08:50:24 +03:00
Catalin Pit d74aca2aa6 feat: save subject and message on blur 2024-08-20 14:19:16 +03:00
166 changed files with 1569 additions and 17077 deletions
-48
View File
@@ -1,48 +0,0 @@
Code Style and Structure:
- Write concise, technical TypeScript code with accurate examples
- Use functional and declarative programming patterns; avoid classes
- Prefer iteration and modularization over code duplication
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError)
- Structure files: exported component, subcomponents, helpers, static content, types
Naming Conventions:
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
- Favor named exports for components
TypeScript Usage:
- Use TypeScript for all code; prefer interfaces over types
- Avoid enums; use maps instead
- Use functional components with TypeScript interfaces
Syntax and Formatting:
- Use the "function" keyword for pure functions
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
- Use declarative JSX
Error Handling and Validation:
- Prioritize error handling: handle errors and edge cases early
- Use early returns and guard clauses
- Implement proper error logging and user-friendly messages
- Use Zod for form validation
- Model expected errors as return values in Server Actions
- Use error boundaries for unexpected errors
UI and Styling:
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
- Implement responsive design with Tailwind CSS; use a mobile-first approach
Performance Optimization:
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC)
- Wrap client components in Suspense with fallback
- Use dynamic loading for non-critical components
- Optimize images: use WebP format, include size data, implement lazy loading
Key Conventions:
- Use 'nuqs' for URL search parameter state management
- Optimize Web Vitals (LCP, CLS, FID)
- Limit 'use client':
- Favor server components and Next.js SSR
- Use only for Web API access in small components
- Avoid for data fetching or state management
Follow Next.js docs for Data Fetching, Rendering, and Routing
@@ -1,5 +1,5 @@
---
title: Getting started
title: Contributing Guide
description: Learn how to contribute to Documenso and become part of our community.
---
@@ -1,4 +0,0 @@
{
"index": "Getting Started",
"contributing-translations": "Contributing Translations"
}
@@ -1,69 +0,0 @@
---
title: Contributing Translations
description: Learn how to contribute translations to Documenso and become part of our community.
---
import { Callout, Steps } from 'nextra/components';
# Contributing Translations
We are always open for help with translations! Currently we utilise AI to generate the initial translations for new languages, which are then improved over time by our awesome community.
If you are looking for development notes on translations, you can find them [here](/developers/local-development/translations).
<Callout type="info">
Contributions are made through GitHub Pull Requests, so you will need a GitHub account to
contribute.
</Callout>
## Overview
We store our translations in PO files, which are located in our GitHub repository [here](https://github.com/documenso/documenso/tree/main/packages/lib/translations).
The translation files are organized into folders represented by their respective language codes (`en` for English, `de` for German, etc). Each language folder contains three PO files:
1. `web.po`: Translations for the web application
2. `marketing.po`: Translations for the marketing application
3. `common.po`: Shared translations between web and marketing
Each PO file contains translations which look like this:
```po
#: apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61
msgid "Want to send slick signing links like this one? <0>Check out Documenso.</0>"
msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso.</0>"
```
- `msgid`: The original text in English (never edit this manually)
- `msgstr`: The translated text in the target language
<Callout type="warning">
Notice the `<0>` tags? These represent HTML elements and must remain in both the `msgid` and `msgstr`. Make sure to translate the content between these tags while keeping the tags intact.
</Callout>
## How to Contribute
### Updating Existing Translations
1. Fork the repository.
2. Navigate to the appropriate language folder.
3. Open the PO file you want to update (web.po, marketing.po, or common.po).
4. Make your changes, ensuring you follow the PO file format.
5. Commit your changes with a message such as `chore: update German translations`
6. Create a Pull Request.
### Adding a New Language
If you want to add translations for a language that doesn't exist yet:
1. Create an issue in our GitHub repository requesting the addition of the new language.
2. Wait for our team to review and approve the request.
3. Once approved, we will set up the necessary files and kickstart the translations with AI to provide initial coverage.
## Need Help?
<Callout type="info">
If you have any questions, hop into our [Discord](https://documen.so/discord) and ask us directly!
</Callout>
Thank you for helping make Documenso more accessible to users around the world!
@@ -10,7 +10,6 @@
"signing-documents": "Signing Documents",
"templates": "Templates",
"direct-links": "Direct Signing Links",
"document-visibility": "Document Visibility",
"-- Legal Overview": {
"type": "separator",
"title": "Legal Overview"
@@ -1,18 +0,0 @@
---
title: Document Visibility
description: Learn how to control the visibility of your team documents.
---
# Team's Document Visibility
By default, all documents created in a team are visible to all team members. However, you can control the visibility of your documents by changing the document's visibility settings.
To set the visibility of a document, click on the **Document visibility** dropdown in the document's settings panel.
![A screenshot of the Documenso's document editor page where you can update the document visibility](/document-visibility-settings.webp)
The document visibility can be set to one of the following options:
- **Everyone** - The document is visible to all team members.
- **Managers and above** - The document is visible to people with the role of Manager or above.
- **Admin only** - The document is only visible to the team's admins.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

@@ -1,85 +0,0 @@
---
title: 'Customer Story Prisma: 4 Reasons why Prisma chose Documenso for Signatures'
description: We are happy to welcome Prisma, another OSS company, as a customer. Read here why they choose us.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-09-26
tags:
- Prisma
- Customer Story
- Open Source
---
<figure>
<MdxNextImage
src="/blog/prisma.png"
width="1200"
height="675"
alt="Primsa Landing Page We simplify database migration, connection pooling, database queries, and readable data models."
/>
<figcaption className="text-center">
Prisma uses Documenso for collaborative team signing.
</figcaption>
</figure>
> TLDR; Prisma is now using Documenso, and [we added visibility scopes](https://docs.documenso.com/users/document-visibility)
# Prisma
Prisma is an open-source company known for its modern OSS ORM (Object-Relational Mapping) tools that simplify database interactions for developers. Their flagship product, Prisma ORM, provides a type-safe way to query databases like PostgreSQL, MySQL, and many more. With the addition of Prisma Studio, an intuitive database management interface, Prisma makes it easier and more efficient for developers to work with databases. With their new additions, Prisma Pulse and Accelerate, you can react to real-time database changes and optimize your queries. And they are completely [open source](https://github.com/prisma/prisma)!
# We choose Prisma too!
I discovered Prisma when planning the tech stack for the [first version of Documenso](https://github.com/documenso/documenso/releases/tag/0.9-developer-preview). Prisma has felt natural to use since day one and has been the base of our database architecture ever since. It's great to see them develop and grow with us.
# Why they choose us
## 1. Signature Flows
Documenso signing flows are highly configurable, designed to adapt to the needs of any document signing process. Whether you're working with different roles, varying settings, or specific delivery methods, Documenso offers the flexibility to suit your requirements. You can choose to send documents via email, share a manual link, generate a link through the API, or even use a static direct link for quick access—all while ensuring a smooth signing experience.
Additionally, you can create templates to streamline and reuse common workflows, saving valuable time. Direct link templates enable users to drive the flow themselves, providing a straightforward path for signing. For a seamless experience, Documenso also allows you to embed the signing process directly into your website, ensuring an uninterrupted, integrated workflow tailored to your needs.
## 2. Modern UX
<figure>
<MdxNextImage
src="/blog/dsux.png"
width="1200"
height="675"
alt="A completed document in Documenso, ready to download."
/>
<figcaption className="text-center">
We call Documenso's design "Happy Minimalism"
</figcaption>
</figure>
Weve crafted Documenso with a sleek, modern interface that makes it incredibly easy to use. Whether youre signing documents, managing workflows, or fine-tuning settings, its intuitive design allows you to accomplish tasks quickly and effortlessly. More than just powerful, Documenso is a pleasure to navigate—designed to be accessible to everyone, no matter their level of tech experience.
## 3. Teams
### Teamwork Makes the Dream Work
Documenso makes teamwork a breeze with its team management features. You can easily set up and organize teams, making it simple to share and manage documents and workflows together. This is a lifesaver for larger organizations or teams spread across different departments, ensuring everyone stays in sync and on track. Different visibility scopes ensure private documents stay private and others are shared for easy collaboration.
### Document Visibility
Collaboration within a team often demands different levels of access to documents. For instance, the Documenso team at Prisma needed a way to set custom visibility on some documents while keeping others accessible to everyone. To address this need, we introduced role-based visibility scopes. This feature allows teams to manage documents more effectively. They can make certain documents visible only to managers or, in special cases, restricted to admins. This ensures sensitive information stays protected while general documents remain accessible to those who need them.
Learn more about visibility scopes and [how they can benefit your team here](https://docs.documenso.com/users/document-visibility).
## 4. OSS!
As you might know, we are open-source! This means you can peek under the hood, tweak things to your liking, and even contribute to making the platform better. We love the community-driven aspect of open-source, and it aligns perfectly with our goal to keep improving and innovating with input from our users.
So, whether you're looking to streamline your document workflows or just need a solid, reliable platform, Documenso has got your back. And we're thrilled to serve another OSS company and help make the space more open.
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Thinking about switching to a modern signing platform? Reach out anytime: [https://documen.so/sales](https://documen.so/sales)
Best from Hamburg\
Timur
@@ -1,86 +0,0 @@
---
title: Go Fork Yourself
description: Curious about our take on open-source and code forking? Discover why we see forking not as a threat but as a vital part of the Open Source ecosystem.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-10-03
tags:
- Culture
- Open Startup
- Open Source
---
> TLDR; At Documenso, we see OSS as co-owned by all. Forking—collaborative or not—is part of the open-source spirit.
## Freedom vs. Ownership
Recently, there has been a lot of debate on the subject of forks and the usage of OSS IP (Open Source Software Intellectual Property). While I mostly aim to stay out of these controversies (as there is no “winning”), I wanted to take this opportunity to share my views on IP and forking culture here at Documenso. I dont presume this is the ideal path, but for me, its the only path that makes sense.
What these issues show foremost, in my opinion, is that the concept of Open Source is still evolving. I have heard many say, “Open Source is clearly defined” and that there is no ambiguity anymore. That may be true on the legal side, but there are vast differences in how these rules are interpreted and lived out. Here are a few questions to illustrate the point:
1. Is it okay to use an open-source project without ever giving back?
2. Is it okay to fork (some might say copy) an OSS product and build something on top of it?
3. Are we morally obliged to fight those who provide different answers to these questions than we do?
## Embracing Forks and Collaboration
Since starting Documenso, Ive thought a lot about what it actually means to be Open Source for us. So far, it has been about openness in working with everyone, from contributors to customers and sharing our work transparently. For this, we have been richly rewarded with attention and reach. This collaborative give-and-take is what people commonly associate with being Open Source, and it seems ideal.
Yet, there are the questions mentioned above. And while these may be contentious, my take is straightforward:
1. Yes.
2. Yes.
3. No.
I say this because, to me, the principles of Open Source are rooted in freedom and collaboration. That means allowing others to use, improve, or even compete with what youve built without feeling possessive over the code. The beauty of Open Source lies in its openness—its ability to be forked, reused, and adapted by anyone.
You may answer these questions differently for your own reasons. One thing Ive found lacking in the discourse is the fact that Open Source is still being treated as socially proprietary. If its under an open-source license, you can fork it and try to improve upon the original, and theres nothing wrong with that. The same is true for closed-source startups. Yet in Open Source, theres a notion that its somehow “dirty,” even though the license explicitly allows it.
## Forking in Action: Real-World Examples
When the team behind **Node.js** disagreed with its governance and pace of development, they forked the project to create **io.js**. This wasnt seen as dirty but as a necessary push for change. In fact, the fork resulted in positive changes—better community governance and faster development—which eventually led to the merge of the two projects under the Node.js Foundation. It shows that forking can be a catalyst for improvement, not just competition.
## The Misconception of “Exploitative” Usage
However, sometimes forks dont merge back but still bring positive change. A good example is **Jenkins**, which was forked from **Hudson** over disagreements in governance after Oracle acquired Sun Microsystems. Jenkins quickly overtook Hudson in terms of community support, development, and innovation. Rather than being seen as a hostile move, the fork enabled Jenkins to become a thriving project, better aligned with the open-source ethos of collaboration and transparency. It emphasizes that forking isnt inherently exploitative; it can simply be a way to realize a projects full potential.
And then theres **MariaDB**, a fork of **MySQL**. After Oracle acquired MySQL, many in the community feared the projects open-source nature could be compromised. The fork preserved its spirit, and MariaDB has since grown to become a popular and thriving database. Its a reminder that sometimes, forking is not just acceptable—its necessary to uphold the values and freedoms of open-source software.
<figure>
<MdxNextImage
src="/blog/owncode.jpeg"
width="1200"
height="675"
alt="Meme: If everyone owns the code, no one does."
/>
<figcaption className="text-center">
Funny Meme to drive the point home.
</figcaption>
</figure>
My view is that the code is not “your” code, just as Documensos code is not “our” code. Its been co-owned by the world ever since we published the repo under AGPL V3. That is the whole point. Its finally not owned by anyone (cue the “everyone/no one” meme). Open Source is for everyone, even competitors. Yet, we are still treating the licenses as extensions of the old, proprietary world and defending perceived injustices based on that model.
> Side Note: Full compliance with all license and other legal rules is a given here.
## Documensos Approach: Co-Ownership and Community
So, if you want to fork Documenso and build a business on it, you can. Whether thats a cool thing to do is another matter. Whether you do a better job than us is also another matter (you wont). But if you do, Ill be the first to join. But why not join us from the start since you already have the upside? We exist because we believe this to be the best way forward—not because we force it.
## The Bigger Picture: Open-Source as Progress
Ive also thought a lot about question #3. I understand the impulse to fight anyone who doesnt appreciate this collaborative approach, but there is no part of this model that backs that up. You are free to “exploit” as long as its in a way that adds value. The fallacy is in considering someone else using the OSS part for their business as treason, which its not. Its the whole point.
While some might say this is theoretical and that reality is different, this is the version of Open Source on which we are building Documenso. The point here is that OSS companies must be resilient to handle forking and competition; without this resilience, an open source driven economy cant thrive. The focus on freedom and collaboration means being prepared for forks and challenges as part of the growth, not as threats.
Of course, all of this applies to Documenso, the OSS project, not Documenso Inc., the company, which is very much a privately owned, for-profit entity. However, since the goal is to scale Documenso to the entire world, there is plenty of room to see everyone as co-owners of the Open Source project rather than as competitors. In the end, Open Source is about progress through freedom. If you dont like how we run things, go fork yourself and hold us accountable. We dont own this; we just happened to start it.
> Since this article is open source as well, you are free to fork it and change it here: [https://documen.so/repo](https://documen.so/repo)
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Thinking about switching to a modern signing platform? Reach out anytime: [https://documen.so/sales](https://documen.so/sales)
Best from Hamburg\
Timur
-55
View File
@@ -8,61 +8,6 @@ Check out what's new in the latest version and read our thoughts on it. For more
---
# Documenso v1.7.1: Signing order and document visibility
We're excited to introduce Documenso v1.7.1, bringing you improved control over your document signing process. Here are the key updates:
## 🌟 Key New Features
### 1. Signing Order
Specify the sequence in which recipients sign your documents. This ensures a structured signing process, particularly useful for complex agreements or hierarchical approvals.
<video
src="/changelog/signing-order-demo.mp4"
className="aspect-video w-full"
autoPlay
loop
controls
/>
### 2. Document Visibility
Manage who can view your documents and when. This feature offers greater privacy and flexibility in your document sharing workflows.
<video
src="/changelog/document-visibility-demo.mp4"
className="aspect-video w-full"
autoPlay
loop
controls
/>
## 🔧 Other Improvements
- Added language switcher for easier language selection
- Support for smaller field bounds in documents
- Improved select field UX
- Enhanced template functionality for advanced fields
- Added authOptions to the API
- Various UI refinements and bug fixes
## 💡 Recent Features
Don't forget about these powerful features from our recent v1.7.0 release:
- Embedded Signing Experience
- Copy and Paste Fields
- Customizable Signature Colors
## 👏 Thank You
As always, we're grateful for our community's contributions and feedback. Your input continues to shape Documenso into the leading open-source document signing solution.
We're eager to see how these new features enhance your document workflows. Enjoy exploring Documenso v1.7.1!
---
# Documenso v1.7.0: Embedded Signing, Copy and Paste, and More
We're thrilled to announce the release of Documenso v1.7.0, packed with exciting new features and improvements that enhance document signing flexibility, user experience, and global accessibility.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.7.2-rc.0",
"version": "1.7.1-rc.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -34,7 +34,7 @@
"micro": "^10.0.1",
"next": "14.2.6",
"next-auth": "4.24.5",
"next-axiom": "^1.5.1",
"next-axiom": "^1.1.1",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

@@ -19,10 +19,10 @@ export const TEAM_MEMBERS = [
},
{
name: 'Ephraim Atta-Duncan',
role: 'Software Engineer - I',
salary: 60_000,
role: 'Software Engineer - Intern',
salary: 15_000,
location: 'Ghana',
engagement: msg`Full-Time`,
engagement: msg`Part-Time`,
joinDate: 'June 6th, 2023',
},
{
@@ -168,7 +168,6 @@ export const SinglePlayerClient = () => {
sendStatus: 'NOT_SENT',
role: 'SIGNER',
authOptions: null,
signingOrder: null,
};
const onFileDrop = async (file: File) => {
@@ -108,21 +108,14 @@ export const Carousel = () => {
return;
}
const newIndex = emblaApi.selectedScrollSnap();
setSelectedIndex(newIndex);
emblaThumbsApi.scrollTo(newIndex);
setSelectedIndex(emblaApi.selectedScrollSnap());
emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap());
resetProgress();
const currentVideo = videoRefs.current[newIndex];
if (currentVideo) {
currentVideo.currentTime = 0;
}
// moduleResolution: bundler breaks this type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const autoplay = emblaApi?.plugins()?.autoplay as unknown as AutoplayType | undefined;
const autoplay = emblaApi.plugins()?.autoplay as unknown as AutoplayType | undefined;
if (autoplay) {
autoplay.reset();
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.7.2-rc.0",
"version": "1.7.1-rc.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -37,7 +37,7 @@
"micro": "^10.0.1",
"next": "14.2.6",
"next-auth": "4.24.5",
"next-axiom": "^1.5.1",
"next-axiom": "^1.1.1",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"papaparse": "^5.4.1",
@@ -53,7 +53,7 @@
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"recharts": "^2.7.2",
"remeda": "^2.12.1",
"remeda": "^1.27.1",
"sharp": "0.32.6",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
@@ -12,12 +12,10 @@ import { getDocumentById } from '@documenso/lib/server-only/document/get-documen
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
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 { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -41,7 +39,7 @@ export type DocumentPageViewProps = {
params: {
id: string;
};
team?: Team & { teamEmail: TeamEmail | null } & { currentTeamMember: { role: TeamMemberRole } };
team?: Team & { teamEmail: TeamEmail | null };
};
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
@@ -64,35 +62,11 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
teamId: team?.id,
}).catch(() => null);
if (document?.teamId && !team?.url) {
redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team?.currentTeamMember?.role;
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (team && !isRecipient) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
}
const isDocumentHistoryEnabled = await getServerComponentFlag(
'app_document_page_view_history_sheet',
);
if (!document || !document.documentData || (team && !canAccessDocument)) {
redirect(documentRootPath);
}
if (team && !canAccessDocument) {
if (!document || !document.documentData) {
redirect(documentRootPath);
}
@@ -85,20 +85,6 @@ export const EditDocumentForm = ({
},
});
const { mutateAsync: setSigningOrderForDocument } =
trpc.document.setSigningOrderForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
);
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newFields) => {
@@ -191,7 +177,6 @@ export const EditDocumentForm = ({
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
@@ -219,22 +204,15 @@ export const EditDocumentForm = ({
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
await Promise.all([
setSigningOrderForDocument({
documentId: document.id,
signingOrder: data.signingOrder,
}),
addSigners({
documentId: document.id,
teamId: team?.id,
signers: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth || null,
})),
}),
]);
await addSigners({
documentId: document.id,
teamId: team?.id,
signers: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth || null,
})),
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
@@ -361,7 +339,6 @@ export const EditDocumentForm = ({
key={recipients.length}
documentFlow={documentFlow.settings}
document={document}
currentTeamMemberRole={team?.currentTeamMember?.role}
recipients={recipients}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
@@ -373,7 +350,6 @@ export const EditDocumentForm = ({
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}
@@ -3,17 +3,14 @@ import { redirect } from 'next/navigation';
import { Plural, Trans } from '@lingui/macro';
import { ChevronLeft, Users2 } from 'lucide-react';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
@@ -24,7 +21,7 @@ export type DocumentEditPageViewProps = {
params: {
id: string;
};
team?: Team & { currentTeamMember: { role: TeamMemberRole } };
team?: Team;
};
export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => {
@@ -46,34 +43,10 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
teamId: team?.id,
}).catch(() => null);
if (document?.teamId && !team?.url) {
redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team?.currentTeamMember?.role;
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
}
if (!document) {
redirect(documentRootPath);
}
if (team && !canAccessDocument) {
redirect(documentRootPath);
}
if (document.status === InternalDocumentStatus.COMPLETED) {
redirect(`${documentRootPath}/${documentId}`);
}
@@ -10,13 +10,12 @@ import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stat
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team, TeamEmail, TeamMemberRole } from '@documenso/prisma/client';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
@@ -26,17 +25,16 @@ import { DataTableSenderFilter } from './data-table-sender-filter';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
export interface DocumentsPageViewProps {
export type DocumentsPageViewProps = {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
senderIds?: string;
search?: string;
};
team?: Team & { teamEmail?: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
}
team?: Team & { teamEmail?: TeamEmail | null };
};
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
@@ -46,16 +44,13 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const search = searchParams.search || '';
const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
const currentTeamMemberRole = team?.currentTeamMember?.role;
const getStatOptions: GetStatsInput = {
user,
period,
search,
};
if (team) {
@@ -63,9 +58,6 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
teamId: team.id,
teamEmail: team.teamEmail?.email,
senderIds,
currentTeamMemberRole,
currentUserEmail: user.email,
userId: user.id,
};
}
@@ -83,7 +75,6 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
perPage,
period,
senderIds,
search,
});
const getTabHref = (value: typeof status) => {
@@ -140,7 +131,10 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
<span className="ml-1 inline-block opacity-50">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
@@ -153,9 +147,6 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={search} />
</div>
</div>
</div>
@@ -73,7 +73,7 @@ export const UserPasskeysDataTable = () => {
cell: ({ row }) =>
row.original.lastUsedAt
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
: _(msg`Never`),
: msg`Never`,
},
{
id: 'actions',
@@ -103,19 +103,6 @@ export const EditTemplateForm = ({
},
});
const { mutateAsync: setSigningOrderForTemplate } =
trpc.template.setSigningOrderForTemplate.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
@@ -173,19 +160,11 @@ export const EditTemplateForm = ({
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await Promise.all([
setSigningOrderForTemplate({
templateId: template.id,
teamId: team?.id,
signingOrder: data.signingOrder,
}),
addTemplateSigners({
templateId: template.id,
teamId: team?.id,
signers: data.signers,
}),
]);
await addTemplateSigners({
templateId: template.id,
teamId: team?.id,
signers: data.signers,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
@@ -283,7 +262,6 @@ export const EditTemplateForm = ({
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
signingOrder={template.templateMeta?.signingOrder}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}
@@ -267,14 +267,14 @@ export const CheckboxField = ({
)}
{field.inserted && (
<div className="flex flex-col gap-y-1">
<div className="flex flex-col gap-y-2">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-3 w-3"
className="h-4 w-4"
checkClassName="text-white"
id={`checkbox-${index}`}
checked={field.customText
@@ -283,7 +283,7 @@ export const CheckboxField = ({
disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)}
/>
<Label htmlFor={`checkbox-${index}`} className="text-xs">
<Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
@@ -204,29 +204,25 @@ export default async function CompletedSigningPage({
</div>
</div>
<div className="flex flex-col items-center">
{canSignUp && (
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
<Trans>Need to sign documents?</Trans>
</h2>
{canSignUp && (
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
<Trans>Need to sign documents?</Trans>
</h2>
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
<Trans>
Create your account and start using state-of-the-art document signing.
</Trans>
</p>
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600">
<Trans>Go Back Home</Trans>
</Link>
)}
</div>
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
<Trans>Go Back Home</Trans>
</Link>
)}
</div>
<PollUntilDocumentCompleted document={document} />
@@ -150,7 +150,7 @@ export const DateField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-sm duration-200">
{localDateString}
</p>
)}
@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -25,19 +25,22 @@ export const DocumentActionAuthAccount = ({
}: DocumentActionAuthAccountProps) => {
const { recipient } = useRequiredDocumentAuthContext();
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
await signOut({
redirect: false,
const encryptedEmail = await encryptSecondaryData({
data: email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
router.push(`/signin#email=${email}`);
await signOut({
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
});
} catch {
setIsSigningOut(false);
@@ -189,10 +189,7 @@ export const DropdownField = ({
},
)}
>
<SelectValue
className="text-[clamp(0.625rem,1cqw,0.825rem)]"
placeholder={`${_(msg`Select`)}`}
/>
<SelectValue placeholder={`${_(msg`Select`)}`} />
</SelectTrigger>
<SelectContent className="w-full ring-0 focus:ring-0" position="popper">
{parsedFieldMeta?.values?.map((item, index) => (
@@ -206,7 +203,7 @@ export const DropdownField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
{field.customText}
</p>
)}
@@ -128,7 +128,7 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
@@ -29,16 +29,9 @@ export type SigningFormProps = {
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
isRecipientsTurn: boolean;
};
export const SigningForm = ({
document,
recipient,
fields,
redirectUrl,
isRecipientsTurn,
}: SigningFormProps) => {
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
@@ -157,7 +150,6 @@ export const SigningForm = ({
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div>
</div>
@@ -221,7 +213,6 @@ export const SigningForm = ({
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div>
</div>
@@ -131,7 +131,7 @@ export const InitialsField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
@@ -172,7 +172,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
@@ -259,7 +259,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
{field.customText}
</p>
)}
@@ -267,7 +267,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Add number</Trans>}
</DialogTitle>
<div>
@@ -9,7 +9,6 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
@@ -43,12 +42,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
@@ -153,7 +146,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
document={document}
fields={fields}
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
/>
</DocumentAuthProvider>
</SigningProvider>
@@ -173,16 +173,16 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
)}
{field.inserted && (
<RadioGroup className="gap-y-1">
<RadioGroup>
{values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="h-3 w-3"
className=""
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
<Label htmlFor={`option-${index}`} className="text-xs">
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
@@ -23,7 +23,6 @@ export type SignDialogProps = {
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
role: RecipientRole;
disabled?: boolean;
};
export const SignDialog = ({
@@ -33,7 +32,6 @@ export const SignDialog = ({
fieldsValidated,
onSignatureComplete,
role,
disabled = false,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(documentTitle);
@@ -56,7 +54,6 @@ export const SignDialog = ({
size="lg"
onClick={fieldsValidated}
loading={isSubmitting}
disabled={disabled}
>
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
@@ -2,12 +2,12 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -20,19 +20,24 @@ export const SigningAuthPageView = ({ email, emailHasAccount }: SigningAuthPageV
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
await signOut({
redirect: false,
const encryptedEmail = await encryptSecondaryData({
data: email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
router.push(emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`);
await signOut({
callbackUrl: emailHasAccount
? `/signin?email=${encodeURIComponent(encryptedEmail)}`
: `/signup?email=${encodeURIComponent(encryptedEmail)}`,
});
} catch {
toast({
title: _(msg`Something went wrong`),
@@ -128,7 +128,7 @@ export const SigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]', type === 'Checkbox' ? 'group' : '')}>
<div className={cn(type === 'Checkbox' ? 'group' : '')}>
<FieldRootContainer field={field}>
{!field.inserted && !loading && !readOnlyField && (
<button
@@ -39,7 +39,6 @@ export type SigningPageViewProps = {
recipient: Recipient;
fields: Field[];
completedFields: CompletedField[];
isRecipientsTurn: boolean;
};
export const SigningPageView = ({
@@ -47,7 +46,6 @@ export const SigningPageView = ({
recipient,
fields,
completedFields,
isRecipientsTurn,
}: SigningPageViewProps) => {
const { documentData, documentMeta } = document;
@@ -101,7 +99,6 @@ export const SigningPageView = ({
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
/>
</div>
</div>
@@ -253,7 +253,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
>
<span className="flex items-center justify-center gap-x-1">
<Type />
{fieldDisplayName || <Trans>Text</Trans>}
{fieldDisplayName || <Trans>Add text</Trans>}
</span>
</p>
)}
@@ -269,7 +269,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Text</Trans>}
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Add Text</Trans>}
</DialogTitle>
<div>
@@ -1,100 +0,0 @@
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
type WaitingForTurnToSignPageProps = {
params: { token?: string };
};
export default async function WaitingForTurnToSignPage({
params: { token },
}: WaitingForTurnToSignPageProps) {
setupI18nSSR();
if (!token) {
return notFound();
}
const { user } = await getServerComponentSession();
const [document, recipient] = await Promise.all([
getDocumentAndSenderByToken({ token }).catch(() => null),
getRecipientByToken({ token }).catch(() => null),
]);
if (!document || !recipient) {
return notFound();
}
if (document.status === DocumentStatus.COMPLETED) {
return redirect(`/sign/${token}/complete`);
}
let isOwnerOrTeamMember = false;
let team: Team | null = null;
if (user) {
isOwnerOrTeamMember = await getDocumentById({
id: document.id,
userId: user.id,
teamId: document.teamId ?? undefined,
})
.then((document) => !!document)
.catch(() => false);
if (document.teamId) {
team = await getTeamById({
userId: user.id,
teamId: document.teamId,
});
}
}
return (
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md text-center">
<h2 className="tracking-tigh text-3xl font-bold">
<Trans>Waiting for Your Turn</Trans>
</h2>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
It's currently not your turn to sign. You will receive an email with instructions once
it's your turn to sign the document.
</Trans>
</p>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>Please check your email for updates.</Trans>
</p>
<div className="mt-4">
{isOwnerOrTeamMember ? (
<Button variant="link" asChild>
<Link href={`${formatDocumentsPath(team?.url)}/${document.id}`}>
<Trans>Were you trying to edit this document instead?</Trans>
</Link>
</Button>
) : (
<Button variant="link" asChild>
<Link href="/documents">Return Home</Link>
</Button>
)}
</div>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { env } from 'next-runtime-env';
@@ -10,6 +11,7 @@ import {
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignInForm } from '~/components/forms/signin';
@@ -17,11 +19,24 @@ export const metadata: Metadata = {
title: 'Sign In',
};
export default function SignInPage() {
type SignInPageProps = {
searchParams: {
email?: string;
};
};
export default function SignInPage({ searchParams }: SignInPageProps) {
setupI18nSSR();
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
if (!email && rawEmail) {
redirect('/signin');
}
return (
<div className="w-screen max-w-lg px-4">
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
@@ -35,6 +50,7 @@ export default function SignInPage() {
<hr className="-mx-6 my-4" />
<SignInForm
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
oidcProviderLabel={OIDC_PROVIDER_LABEL}
@@ -5,6 +5,7 @@ import { env } from 'next-runtime-env';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpFormV2 } from '~/components/forms/v2/signup';
@@ -12,7 +13,13 @@ export const metadata: Metadata = {
title: 'Sign Up',
};
export default function SignUpPage() {
type SignUpPageProps = {
searchParams: {
email?: string;
};
};
export default function SignUpPage({ searchParams }: SignUpPageProps) {
setupI18nSSR();
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
@@ -21,9 +28,17 @@ export default function SignUpPage() {
redirect('/signin');
}
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
if (!email && rawEmail) {
redirect('/signup');
}
return (
<SignUpFormV2
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
/>
+1 -4
View File
@@ -1,8 +1,5 @@
import { z } from 'zod';
export const ZBaseEmbedDataSchema = z.object({
css: z
.string()
.optional()
.transform((value) => value || undefined),
css: z.string().optional().transform(value => value || undefined),
});
+3 -6
View File
@@ -18,18 +18,15 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
<div className="mt-8 w-full max-w-md">
<SigningCard3D
className="mx-auto w-full"
className='w-full mx-auto'
name={name || 'Documenso'}
signature={signature}
signingCelebrationImage={signingCelebration}
/>
</div>
<p className="text-muted-foreground mt-8 max-w-[50ch] text-center text-sm">
<Trans>
The document is now completed, please follow any instructions provided within the parent
application.
</Trans>
<p className="mt-8 max-w-[50ch] text-center text-muted-foreground text-sm">
<Trans>The document is now completed, please follow any instructions provided within the parent application.</Trans>
</p>
</div>
);
@@ -6,7 +6,6 @@ import { useSearchParams } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
@@ -15,7 +14,7 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
import { FieldType, type DocumentData, type Field } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -35,6 +34,7 @@ import type { DirectTemplateLocalField } from '~/app/(recipient)/d/[token]/sign-
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { Logo } from '~/components/branding/logo';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
@@ -307,7 +307,7 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
<div className="relative flex flex-col md:flex-row w-full gap-x-6 gap-y-12">
{/* Viewer */}
<div className="flex-1">
<LazyPDFViewer
@@ -318,26 +318,26 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */}
<div
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
className="group/document-widget fixed md:sticky md:top-4 left-0 w-full bottom-8 px-6 md:px-0 z-50 md:z-auto md:w-[350px] flex-shrink-0 h-fit"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
<div className="w-full border-border bg-widget flex md:min-h-[min(calc(100dvh-2rem),48rem)] h-fit flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<h3 className="text-foreground text-xl md:text-2xl font-semibold">
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(true)}
/>
)}
@@ -354,7 +354,7 @@ export const EmbedDirectTemplateClientPage = ({
</div>
{/* Form */}
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="-mx-2 px-2 hidden group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
@@ -408,9 +408,9 @@ export const EmbedDirectTemplateClientPage = ({
</div>
</div>
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<div className="flex-1 hidden group-data-[expanded]/document-widget:block md:block" />
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
<div className="w-full grid-cols-2 items-center mt-4 hidden group-data-[expanded]/document-widget:grid md:grid">
{pendingFields.length > 0 ? (
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
<Trans>Next</Trans>
@@ -1,3 +1,3 @@
export default function EmbedDirectTemplateNotFound() {
return <div>Not Found</div>;
return <div>Not Found</div>
}
@@ -73,7 +73,11 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
const fields = template.Field.filter((field) => field.recipientId === directTemplateRecipientId);
return (
<SigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
<SigningProvider
email={user?.email}
fullName={user?.name}
signature={user?.signature}
>
<DocumentAuthProvider
documentAuthOptions={template.authOptions}
recipient={recipient}
+1 -1
View File
@@ -13,7 +13,7 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import { type Field, FieldType } from '@documenso/prisma/client';
import { FieldType, type Field } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
+4 -6
View File
@@ -1,7 +1,5 @@
export const EmbedPaywall = () => {
return (
<div>
<h1>Paywall</h1>
</div>
);
};
return <div>
<h1>Paywall</h1>
</div>
}
@@ -1,17 +1,15 @@
'use client';
import { useEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useEffect, useState } from 'react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -22,9 +20,9 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
@@ -187,7 +185,7 @@ export const EmbedSignDocumentClientPage = ({
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
<div className="relative flex flex-col md:flex-row w-full gap-x-6 gap-y-12">
{/* Viewer */}
<div className="flex-1">
<LazyPDFViewer
@@ -198,26 +196,26 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */}
<div
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
className="group/document-widget fixed md:sticky md:top-4 left-0 w-full bottom-8 px-6 md:px-0 z-50 md:z-auto md:w-[350px] flex-shrink-0 h-fit"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
<div className="w-full border-border bg-widget flex flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<h3 className="text-foreground text-xl md:text-2xl font-semibold">
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(true)}
/>
)}
@@ -234,7 +232,7 @@ export const EmbedSignDocumentClientPage = ({
</div>
{/* Form */}
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="-mx-2 px-2 hidden group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
@@ -287,9 +285,9 @@ export const EmbedSignDocumentClientPage = ({
</div>
</div>
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<div className="flex-1 hidden group-data-[expanded]/document-widget:block md:block" />
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
<div className="w-full grid-cols-2 items-center mt-4 hidden group-data-[expanded]/document-widget:grid md:grid">
{pendingFields.length > 0 ? (
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
<Trans>Next</Trans>
@@ -1,3 +1,3 @@
export default function EmbedDirectTemplateNotFound() {
return <div>Not Found</div>;
return <div>Not Found</div>
}
@@ -4,12 +4,8 @@ import { match } from 'ts-pattern';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
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 { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus } from '@documenso/prisma/client';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
@@ -17,6 +13,10 @@ import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { EmbedAuthenticateView } from '../../authenticate';
import { EmbedPaywall } from '../../paywall';
import { EmbedSignDocumentClientPage } from './client';
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 { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { DocumentStatus } from '@documenso/prisma/client';
export type EmbedSignDocumentPageProps = {
params: {
@@ -66,12 +66,7 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
.exhaustive();
if (!isAccessAuthValid) {
return (
<EmbedAuthenticateView
email={user?.email || recipient.email}
returnTo={`/embed/direct/${token}`}
/>
);
return <EmbedAuthenticateView email={user?.email || recipient.email} returnTo={`/embed/direct/${token}`} />;
}
return (
@@ -1,11 +1,9 @@
'use client';
import { useMemo } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
@@ -31,26 +29,24 @@ export const StackAvatarsWithTooltip = ({
const { _ } = useLingui();
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
(recipient) => getRecipientType(recipient) === 'waiting',
);
const openedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED,
(recipient) => getRecipientType(recipient) === 'opened',
);
const completedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
(recipient) => getRecipientType(recipient) === 'completed',
);
const uncompletedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
(recipient) => getRecipientType(recipient) === 'unsigned',
);
const sortedRecipients = useMemo(() => recipients.sort((a, b) => a.id - b.id), [recipients]);
return (
<PopoverHover
trigger={children || <StackAvatars recipients={sortedRecipients} />}
trigger={children || <StackAvatars recipients={recipients} />}
contentProps={{
className: 'flex flex-col gap-y-5 py-2',
side: position,
@@ -69,7 +65,7 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
@@ -1,9 +1,6 @@
import React from 'react';
import {
getExtraRecipientsType,
getRecipientType,
} from '@documenso/lib/client-only/recipient-type';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
@@ -16,27 +13,20 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient: Recipient, index: number) => {
const first = index === 0;
const first = index === 0 ? true : false;
if (index === 4 && remainingItems > 0) {
return (
<StackAvatar
key="extra-recipient"
first={first}
zIndex={String(zIndex - index * 10)}
type={getExtraRecipientsType(recipients.slice(4))}
fallbackText={`+${remainingItems + 1}`}
/>
);
}
const lastItemText =
index === itemsToRender.length - 1 && remainingItems > 0
? `+${remainingItems + 1}`
: undefined;
return (
<StackAvatar
key={recipient.id}
first={first}
zIndex={String(zIndex - index * 10)}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)}
/>
);
});
@@ -1,41 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
const router = useRouter();
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState(initialValue);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
const handleSearch = useCallback(
(term: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? '');
if (term) {
params.set('search', term);
} else {
params.delete('search');
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
useEffect(() => {
handleSearch(searchTerm);
}, [debouncedSearchTerm]);
return (
<Input
type="search"
placeholder="Search documents..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
);
};
@@ -335,23 +335,6 @@ export const DocumentHistorySheet = ({
]}
/>
))
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
),
)
.exhaustive()}
{isUserDetailsVisible && (
@@ -89,7 +89,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl">
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{field.Signature?.typedSignature}
</p>
),
@@ -122,7 +122,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
{field.Recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<p
className={cn('text-muted-foreground text-lg duration-200', {
'font-signature sm:text-xl md:text-2xl':
'font-signature sm:text-xl md:text-2xl lg:text-3xl':
field.type === FieldType.SIGNATURE ||
field.type === FieldType.FREE_SIGNATURE,
})}
+1 -13
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
@@ -307,18 +307,6 @@ export const SignInForm = ({
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const email = params.get('email');
if (email) {
form.setValue('email', email);
}
}, [form]);
return (
<Form {...form}>
<form
+1 -13
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
@@ -203,18 +203,6 @@ export const SignUpFormV2 = ({
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const email = params.get('email');
if (email) {
form.setValue('email', email);
}
}, [form]);
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
+1 -1
View File
@@ -81,7 +81,7 @@ async function middleware(req: NextRequest): Promise<NextResponse> {
// Allow third parties to iframe the document.
res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.headers.set('Access-Control-Allow-Origin', '*');
res.headers.set('Content-Security-Policy', 'frame-ancestors *');
res.headers.set('Content-Security-Policy', "frame-ancestors *");
res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
res.headers.set('X-Content-Type-Options', 'nosniff');
res.headers.set('X-Frame-Options', 'ALLOW-ALL');
+3 -3
View File
@@ -3,14 +3,14 @@
import { createContext, useContext } from 'react';
import React from 'react';
import type { GetTeamResponse } from '@documenso/lib/server-only/team/get-team';
import type { Team } from '@documenso/prisma/client';
interface TeamProviderProps {
children: React.ReactNode;
team: GetTeamResponse;
team: Team;
}
const TeamContext = createContext<GetTeamResponse | null>(null);
const TeamContext = createContext<Team | null>(null);
export const useCurrentTeam = () => {
const context = useContext(TeamContext);
-3
View File
@@ -1,3 +0,0 @@
/bin/
/node_modules/
-2
View File
@@ -1,2 +0,0 @@
config:
gcp:project: scratchpad-438301
-10
View File
@@ -1,10 +0,0 @@
name: gcloud
runtime:
name: nodejs
options:
packagemanager: npm
description: documenso on google cloud
config:
app--url: 'https://app.documenso.com'
app--smtp-from-name: 'Documenso'
app--smtp-from-address: 'noreply@documenso.com'
-418
View File
@@ -1,418 +0,0 @@
import * as gcp from '@pulumi/gcp';
import * as pulumi from '@pulumi/pulumi';
import * as random from '@pulumi/random';
const config = new pulumi.Config();
// Storage Config
const storageLocation = config.get('storage--location') || 'EU';
const serviceLocation = config.get('service--location') || 'europe-west3';
// KMS Config
const algorithm = config.get('kms--algorithm') || 'RSA_SIGN_PKCS1_4096_SHA256';
// Database Config
const databasePassword = config.get('db--password') || 'password';
// App Config
// App Config
const appUrl = config.require('app--url');
const nextAuthSecret =
config.getSecret('app--nextauth-secret') ||
new random.RandomString('next-auth-secret', {
length: 32,
}).result;
const encryptionKey =
config.getSecret('app--encryption-key') ||
new random.RandomString('encryption-key', {
length: 32,
}).result;
const encryptionSecondaryKey =
config.getSecret('app--encryption-secondary-key') ||
new random.RandomString('encryption-secondary-key', {
length: 32,
}).result;
const googleClientId = config.get('app--google-client-id') || '';
const googleClientSecret = config.get('app--google-client-secret') || '';
const oidcWellKnown = config.get('app--oidc-well-known') || '';
const oidcClientId = config.get('app--oidc-client-id') || '';
const oidcClientSecret = config.get('app--oidc-client-secret') || '';
const oidcProviderLabel = config.get('app--oidc-provider-label') || 'OIDC';
const oidcAllowSignup = config.get('app--oidc-allow-signup') || '';
const oidcSkipVerify = config.get('app--oidc-skip-verify') || '';
const internalWebappUrl = config.get('app--internal-webapp-url') || '';
const smtpTransport = config.get('app--smtp-transport') || 'smtp-auth';
const smtpHost = config.get('app--smtp-host') || '';
const smtpPort = config.get('app--smtp-port') || '';
const smtpUsername = config.get('app--smtp-username') || '';
const smtpPassword = config.getSecret('app--smtp-password') || '';
const smtpApikeyUser = config.get('app--smtp-apikey-user') || '';
const smtpApikey = config.getSecret('app--smtp-apikey') || '';
const smtpSecure = config.get('app--smtp-secure') || '';
const smtpUnsafeIgnoreTls = config.get('app--smtp-unsafe-ignore-tls') || '';
const smtpFromName = config.require('app--smtp-from-name');
const smtpFromAddress = config.require('app--smtp-from-address');
const resendApiKey = config.getSecret('app--resend-api-key') || '';
const documentSizeUploadLimit = config.get('app--document-size-upload-limit') || '50';
const stripeApiKey = config.getSecret('app--stripe-api-key') || '';
const stripeWebhookSecret = config.getSecret('app--stripe-webhook-secret') || '';
const stripeEnterprisePlanMonthlyPriceId =
config.get('app--stripe-enterprise-plan-monthly-price-id') || '';
const jobsProvider = config.get('app--jobs-provider') || 'local';
const triggerApiKey = config.get('app--trigger-api-key') || '';
const triggerApiUrl = config.get('app--trigger-api-url') || '';
const inngestEventKey = config.get('app--inngest-event-key') || '';
const posthogKey = config.get('app--posthog-key') || '';
const billingEnabled = config.get('app--billing-enabled') || 'false';
new gcp.projects.Service('cloud-kms-api', {
project: gcp.config.project,
service: 'cloudkms.googleapis.com',
disableOnDestroy: false,
});
new gcp.projects.Service('cloud-run-api', {
project: gcp.config.project,
service: 'run.googleapis.com',
disableOnDestroy: false,
});
new gcp.projects.Service('compute-api', {
project: gcp.config.project,
service: 'compute.googleapis.com',
disableOnDestroy: false,
});
const signupDisabled = config.get('app--signup-disabled') || 'false';
// Create a GCS bucket for storage
const storageBucket = new gcp.storage.Bucket('documenso-storage', {
name: 'documenso-storage',
location: storageLocation,
});
// Create a service account so we can create a HMAC key to use the S3-compatible storage
// API
const storageServiceAccount = new gcp.serviceaccount.Account('documenso-storage-sa', {
accountId: 'documenso-storage-sa',
displayName: 'Storage Service Account',
});
const appServiceAccount = new gcp.serviceaccount.Account('documenso-app-sa', {
accountId: 'documenso-app-sa',
displayName: 'App Service Account',
});
// Create the HMAC key for the storage service account
const hmacKey = new gcp.storage.HmacKey('documenso-storage-key', {
serviceAccountEmail: storageServiceAccount.email,
});
// Create a Cloud HSM cluster
const hsmCluster = new gcp.kms.KeyRing('hsm-keyring', {
name: 'documenso-hsm-keyring',
location: serviceLocation,
});
const hsmKey = new gcp.kms.CryptoKey('hsm-key', {
name: 'documenso-hsm-key',
keyRing: hsmCluster.id,
purpose: 'ASYMMETRIC_SIGN',
versionTemplate: {
algorithm,
protectionLevel: 'HSM',
},
});
// Create the database
const database = new gcp.sql.DatabaseInstance('documenso-db', {
name: 'documenso-db',
databaseVersion: 'POSTGRES_15',
region: serviceLocation,
settings: {
tier: 'db-custom-2-4096',
diskSize: 50,
diskType: 'PD_SSD',
diskAutoresize: true,
ipConfiguration: {
ipv4Enabled: true,
},
backupConfiguration: {
enabled: true,
startTime: '02:00',
backupRetentionSettings: {
retainedBackups: 30,
},
pointInTimeRecoveryEnabled: true,
transactionLogRetentionDays: 7,
},
databaseFlags: [
{ name: 'max_connections', value: '100' },
{ name: 'log_min_duration_statement', value: '300' },
],
maintenanceWindow: {
day: 7,
hour: 3,
},
insightsConfig: {
queryInsightsEnabled: true,
queryStringLength: 1024,
recordApplicationTags: true,
recordClientAddress: true,
},
},
deletionProtection: true,
});
const user = new gcp.sql.User('documenso-db-user', {
name: 'documenso',
instance: database.name,
password: databasePassword,
});
// Build and deploy the containerized application using Cloud Run
const appService = new gcp.cloudrun.Service('documenso-app', {
name: 'documenso-app',
location: serviceLocation,
template: {
metadata: {
annotations: {
'run.googleapis.com/cloudsql-instances': database.connectionName,
},
},
spec: {
containers: [
{
image: 'documenso/documenso:latest',
resources: {
limits: {
memory: '4Gi',
cpu: '4',
},
},
ports: [{ containerPort: 3000 }],
envs: [
{
name: 'NEXTAUTH_URL',
value: appUrl,
},
{
name: 'NEXTAUTH_SECRET',
value: nextAuthSecret,
},
{
name: 'NEXT_PRIVATE_ENCRYPTION_KEY',
value: encryptionKey,
},
{
name: 'NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY',
value: encryptionSecondaryKey,
},
{
name: 'NEXT_PRIVATE_GOOGLE_CLIENT_ID',
value: googleClientId,
},
{
name: 'NEXT_PRIVATE_GOOGLE_CLIENT_SECRET',
value: googleClientSecret,
},
{
name: 'NEXT_PRIVATE_OIDC_WELL_KNOWN',
value: oidcWellKnown,
},
{
name: 'NEXT_PRIVATE_OIDC_CLIENT_ID',
value: oidcClientId,
},
{
name: 'NEXT_PRIVATE_OIDC_CLIENT_SECRET',
value: oidcClientSecret,
},
{
name: 'NEXT_PRIVATE_OIDC_PROVIDER_LABEL',
value: oidcProviderLabel,
},
{
name: 'NEXT_PRIVATE_OIDC_ALLOW_SIGNUP',
value: oidcAllowSignup,
},
{
name: 'NEXT_PRIVATE_OIDC_SKIP_VERIFY',
value: oidcSkipVerify,
},
{
name: 'NEXT_PUBLIC_WEBAPP_URL',
value: appUrl,
},
{
name: 'NEXT_PRIVATE_INTERNAL_WEBAPP_URL',
value: internalWebappUrl,
},
{
name: 'NEXT_PRIVATE_DATABASE_URL',
value: pulumi.interpolate`postgres://${user.name}:${user.password}@localhost:5432/documenso?host=/cloudsql/${database.connectionName}/`,
},
{
name: 'NEXT_PRIVATE_DIRECT_DATABASE_URL',
value: pulumi.interpolate`postgres://${user.name}:${user.password}@localhost:5432/documenso?host=/cloudsql/${database.connectionName}/`,
},
{
name: 'NEXT_PRIVATE_SIGNING_TRANSPORT',
value: 'gcloud-hsm',
},
{
name: 'NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH',
value: hsmKey.id,
},
{
name: 'NEXT_PUBLIC_UPLOAD_TRANSPORT',
value: 's3',
},
{
name: 'NEXT_PRIVATE_UPLOAD_ENDPOINT',
value: 'https://storage.googleapis.com',
},
{
name: 'NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE',
value: 'true',
},
{
name: 'NEXT_PRIVATE_UPLOAD_REGION',
value: 'auto',
},
{
name: 'NEXT_PRIVATE_UPLOAD_BUCKET',
value: storageBucket.name,
},
{
name: 'NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID',
value: hmacKey.accessId,
},
{
name: 'NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY',
value: hmacKey.secret,
},
{
name: 'NEXT_PRIVATE_SMTP_TRANSPORT',
value: smtpTransport,
},
{
name: 'NEXT_PRIVATE_SMTP_HOST',
value: smtpHost,
},
{
name: 'NEXT_PRIVATE_SMTP_PORT',
value: smtpPort,
},
{
name: 'NEXT_PRIVATE_SMTP_USERNAME',
value: smtpUsername,
},
{
name: 'NEXT_PRIVATE_SMTP_PASSWORD',
value: smtpPassword,
},
{
name: 'NEXT_PRIVATE_SMTP_APIKEY_USER',
value: smtpApikeyUser,
},
{
name: 'NEXT_PRIVATE_SMTP_APIKEY',
value: smtpApikey,
},
{
name: 'NEXT_PRIVATE_SMTP_SECURE',
value: smtpSecure,
},
{
name: 'NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS',
value: smtpUnsafeIgnoreTls,
},
{
name: 'NEXT_PRIVATE_SMTP_FROM_NAME',
value: smtpFromName,
},
{
name: 'NEXT_PRIVATE_SMTP_FROM_ADDRESS',
value: smtpFromAddress,
},
{
name: 'NEXT_PRIVATE_RESEND_API_KEY',
value: resendApiKey,
},
{
name: 'NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT',
value: documentSizeUploadLimit,
},
{
name: 'NEXT_PRIVATE_STRIPE_API_KEY',
value: stripeApiKey,
},
{
name: 'NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET',
value: stripeWebhookSecret,
},
{
name: 'NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID',
value: stripeEnterprisePlanMonthlyPriceId,
},
{
name: 'NEXT_PRIVATE_JOBS_PROVIDER',
value: jobsProvider,
},
{
name: 'NEXT_PRIVATE_TRIGGER_API_KEY',
value: triggerApiKey,
},
{
name: 'NEXT_PRIVATE_TRIGGER_API_URL',
value: triggerApiUrl,
},
{
name: 'NEXT_PRIVATE_INNGEST_EVENT_KEY',
value: inngestEventKey,
},
{
name: 'NEXT_PUBLIC_POSTHOG_KEY',
value: posthogKey,
},
{
name: 'NEXT_PUBLIC_FEATURE_BILLING_ENABLED',
value: billingEnabled,
},
{
name: 'NEXT_PUBLIC_DISABLE_SIGNUP',
value: signupDisabled,
},
],
},
],
timeoutSeconds: 3600,
serviceAccountName: appServiceAccount.email,
},
},
});
// Allow unauthenticated invocations
const iam = new gcp.cloudrun.IamMember('documenso-app-invoker', {
service: appService.name,
location: appService.location,
role: 'roles/run.invoker',
member: 'allUsers',
});
// Allow the Cloud Run service to use the HSM for signing
const _cryptoKeyIAMBinding = new gcp.kms.CryptoKeyIAMBinding('cryptoKeyIAMBinding', {
cryptoKeyId: hsmKey.id,
role: 'roles/cloudkms.signerVerifier',
members: [appServiceAccount.email.apply((email) => `serviceAccount:${email}`)],
});
// Allow the Cloud Run service to access the GCS Bucket
const _bucketIAMBinding = new gcp.storage.BucketIAMBinding('bucketIAMBinding', {
bucket: storageBucket.name,
role: 'roles/storage.objectAdmin',
members: [appServiceAccount.email.apply((email) => `serviceAccount:${email}`)],
});
export const serviceUrl = appService.statuses[0].url;
-3631
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
{
"name": "gcloud",
"description": "documenso on google cloud",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"dependencies": {
"@pulumi/pulumi": "*",
"@pulumi/gcp": "7.8.0",
"@pulumi/google-native": "0.31.1",
"@pulumi/random": "^4.16.6"
}
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2016",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"."
],
"exclude": [
"node_modules"
]
}
+160 -2648
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.7.2-rc.0",
"version": "1.7.1-rc.0",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
@@ -59,8 +59,7 @@
"name": "@documenso/root",
"workspaces": [
"apps/*",
"packages/*",
"infra/*"
"packages/*"
],
"dependencies": {
"@documenso/pdf-sign": "^0.1.0",
@@ -75,4 +74,4 @@
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}
+4 -16
View File
@@ -293,7 +293,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
timezone,
dateFormat: dateFormat?.value,
redirectUrl: body.meta.redirectUrl,
signingOrder: body.meta.signingOrder,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
@@ -302,9 +301,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
documentId: document.id,
userId: user.id,
teamId: team?.id,
data: {
...body.authOptions,
},
data: body.authOptions,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}
@@ -328,7 +325,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingOrder: recipient.signingOrder,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
@@ -500,7 +496,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingOrder: recipient.signingOrder,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
@@ -593,7 +588,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingOrder: recipient.signingOrder,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
@@ -719,7 +713,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
createRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
const { name, email, role, authOptions, signingOrder } = args.body;
const { name, email, role, authOptions } = args.body;
const document = await getDocumentById({
id: Number(documentId),
@@ -768,16 +762,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
userId: user.id,
teamId: team?.id,
recipients: [
...recipients.map(({ email, name }) => ({
email,
name,
role,
})),
...recipients,
{
email,
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth ?? null,
},
],
@@ -810,7 +799,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
updateRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, recipientId } = args.params;
const { name, email, role, authOptions, signingOrder } = args.body;
const { name, email, role, authOptions } = args.body;
const document = await getDocumentById({
id: Number(documentId),
@@ -844,7 +833,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email,
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null);
-15
View File
@@ -13,7 +13,6 @@ import {
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
DocumentDataType,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
@@ -104,7 +103,6 @@ export const ZCreateDocumentMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
}),
),
meta: z
@@ -125,7 +123,6 @@ export const ZCreateDocumentMutationSchema = z.object({
enum: DATE_FORMATS.map((format) => format.value),
}),
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
})
.partial(),
authOptions: z
@@ -150,7 +147,6 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
signingUrl: z.string(),
}),
@@ -169,7 +165,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
}),
),
meta: z
@@ -179,7 +174,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
})
.partial()
.optional(),
@@ -206,7 +200,6 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
signingUrl: z.string(),
}),
@@ -226,7 +219,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
id: z.number(),
name: z.string().optional(),
email: z.string().email().min(1),
signingOrder: z.number().nullish(),
}),
)
.refine(
@@ -245,7 +237,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: ZUrlSchema,
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
})
.partial()
.optional(),
@@ -272,7 +263,6 @@ export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
signingUrl: z.string(),
}),
@@ -287,7 +277,6 @@ export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
authOptions: z
.object({
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
@@ -316,7 +305,6 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
token: z.string(),
// !: Not used for now
// expired: z.string(),
@@ -434,7 +422,6 @@ export const ZTemplateMetaSchema = z.object({
dateFormat: z.string().nullish(),
templateId: z.number(),
redirectUrl: z.string().nullish(),
signingOrder: z.nativeEnum(DocumentSigningOrder).nullish().default(DocumentSigningOrder.PARALLEL),
});
export const ZTemplateSchema = z.object({
@@ -456,7 +443,6 @@ export const ZRecipientSchema = z.object({
email: z.string().email().min(1),
name: z.string(),
token: z.string(),
signingOrder: z.number().nullish(),
documentDeletedAt: z.date().nullish(),
expired: z.date().nullish(),
signedAt: z.date().nullish(),
@@ -510,7 +496,6 @@ export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
id: true,
email: true,
name: true,
signingOrder: true,
authOptions: true,
role: true,
}).array(),
@@ -41,10 +41,11 @@ test.describe('[EE_ONLY]', () => {
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').check();
@@ -76,11 +77,9 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
@@ -4,13 +4,7 @@ import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import {
DocumentSigningOrder,
DocumentStatus,
FieldType,
RecipientRole,
SigningStatus,
} from '@documenso/prisma/client';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
@@ -143,9 +137,8 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('user2@example.com');
await page.getByLabel('Name').nth(1).fill('User 2');
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
await page.getByRole('button', { name: 'Continue' }).click();
@@ -224,20 +217,20 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('user2@example.com');
await page.getByLabel('Name').nth(1).fill('User 2');
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
await page.locator('button[role="combobox"]').nth(1).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(2).fill('user3@example.com');
await page.getByLabel('Name').nth(2).fill('User 3');
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(1).fill('user3@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(2).fill('User 3');
await page.locator('button[role="combobox"]').nth(2).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(3).fill('user4@example.com');
await page.getByLabel('Name').nth(3).fill('User 4');
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(2).fill('user4@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(3).fill('User 4');
await page.locator('button[role="combobox"]').nth(3).click();
await page.getByLabel('Needs to view').click();
@@ -510,163 +503,3 @@ test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', asyn
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recipients in sequential order', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `Sequential-Signing-${Date.now()}.pdf`;
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
for (let i = 1; i <= 3; i++) {
if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click();
}
await page
.getByPlaceholder('Email')
.nth(i - 1)
.fill(`user${i}@example.com`);
await page
.getByPlaceholder('Name')
.nth(i - 1)
.fill(`User ${i}`);
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
for (let i = 1; i <= 3; i++) {
if (i > 1) {
await page.getByText(`User ${i} (user${i}@example.com)`).click();
}
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100 * i,
},
});
await page.getByText(`User ${i} (user${i}@example.com)`).click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
const createdDocument = await prisma.document.findFirst({
where: { title: documentTitle },
include: { Recipient: true },
});
expect(createdDocument).not.toBeNull();
expect(createdDocument?.Recipient.length).toBe(3);
for (let i = 0; i < 3; i++) {
const recipient = createdDocument?.Recipient.find(
(r) => r.email === `user${i + 1}@example.com`,
);
expect(recipient).not.toBeNull();
const fields = await prisma.field.findMany({
where: { recipientId: recipient?.id, documentId: createdDocument?.id },
});
const recipientField = fields[0];
if (i > 0) {
const previousRecipient = await prisma.recipient.findFirst({
where: { email: `user${i}@example.com`, documentId: createdDocument?.id },
});
expect(previousRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
}
await page.goto(`/sign/${recipient?.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
const canvas = page.locator('canvas#signature');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Sign', exact: true }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${recipient?.token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
const updatedRecipient = await prisma.recipient.findFirst({
where: { id: recipient?.id },
});
expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
}
// Wait for the document to be signed.
await page.waitForTimeout(5000);
const finalDocument = await prisma.document.findFirst({
where: { id: createdDocument?.id },
});
expect(finalDocument?.status).toBe(DocumentStatus.COMPLETED);
});
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({
page,
}) => {
const user = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
fields: [FieldType.SIGNATURE],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
updateDocumentOptions: {
documentMeta: {
create: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
});
const pendingRecipient = recipients.find((r) => r.signingOrder === 2);
await page.goto(`/sign/${pendingRecipient?.token}`);
await expect(page).toHaveURL(`/sign/${pendingRecipient?.token}/waiting`);
const activeRecipient = recipients.find((r) => r.signingOrder === 1);
await page.goto(`/sign/${activeRecipient?.token}`);
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
});
@@ -1,249 +0,0 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test('[TEAMS]: search respects team document visibility', async ({ page }) => {
const team = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedDocuments([
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Searchable Document for Everyone',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Searchable Document for Managers',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Searchable Document for Admins',
},
},
]);
const testCases = [
{ user: adminUser, visibleDocs: 3 },
{ user: managerUser, visibleDocs: 2 },
{ user: memberUser, visibleDocs: 1 },
];
for (const { user, visibleDocs } of testCases) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Searchable');
await page.waitForURL(/search=Searchable/);
await checkDocumentTabCount(page, 'All', visibleDocs);
await apiSignout({ page });
}
});
test('[TEAMS]: search does not reveal documents from other teams', async ({ page }) => {
const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();
const { team: teamB } = await seedTeamDocuments();
await seedDocuments([
{
sender: teamA.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: teamA.id,
visibility: 'EVERYONE',
title: 'Unique Team A Document',
},
},
{
sender: teamB.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: teamB.id,
visibility: 'EVERYONE',
title: 'Unique Team B Document',
},
},
]);
await apiSignin({
page,
email: teamAMember.email,
redirectPath: `/t/${teamA.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Unique');
await page.waitForURL(/search=Unique/);
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Unique Team A Document' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Unique Team B Document' })).not.toBeVisible();
await apiSignout({ page });
});
test('[PERSONAL]: search does not reveal team documents in personal account', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments();
await seedDocuments([
{
sender: teamMember2,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: null,
title: 'Personal Unique Document',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Team Unique Document',
},
},
]);
await apiSignin({
page,
email: teamMember2.email,
redirectPath: '/documents',
});
await page.getByPlaceholder('Search documents...').fill('Unique');
await page.waitForURL(/search=Unique/);
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Personal Unique Document' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team Unique Document' })).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({
page,
}) => {
const team = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedDocuments([
{
sender: team.owner,
recipients: [memberUser],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document with Member Recipient',
},
},
]);
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Admin Document');
await page.waitForURL(/search=Admin(%20|\+|\s)Document/);
await checkDocumentTabCount(page, 'All', 1);
await expect(
page.getByRole('link', { name: 'Admin Document with Member Recipient' }),
).toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: search by recipient name respects visibility', async ({ page }) => {
const team = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
name: 'Team Member',
});
const uniqueRecipient = await seedUser();
await seedDocuments([
{
sender: team.owner,
recipients: [uniqueRecipient],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document for Unique Recipient',
},
},
]);
// Admin should see the document when searching by recipient name
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
await checkDocumentTabCount(page, 'All', 1);
await expect(
page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
).toBeVisible();
await apiSignout({ page });
// Member should not see the document when searching by recipient name
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
await checkDocumentTabCount(page, 'All', 0);
await expect(
page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
).not.toBeVisible();
await apiSignout({ page });
});
@@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedTeamEmail } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
@@ -355,354 +355,3 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
await apiSignout({ page });
}
});
test('[TEAMS]: check document visibility based on team member role', async ({ page }) => {
const team = await seedTeam();
// Seed users with different roles
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
const managerUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const outsideUser = await seedUser();
// Seed documents with different visibility levels
await seedDocuments([
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Document Visible to Everyone',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Document Visible to Manager and Above',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Document Visible to Admin',
},
},
{
sender: team.owner,
recipients: [outsideUser],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Document Visible to Admin with Recipient',
},
},
]);
// Test cases for each role
const testCases = [
{
user: adminUser,
expectedDocuments: [
'Document Visible to Everyone',
'Document Visible to Manager and Above',
'Document Visible to Admin',
'Document Visible to Admin with Recipient',
],
},
{
user: managerUser,
expectedDocuments: ['Document Visible to Everyone', 'Document Visible to Manager and Above'],
},
{
user: memberUser,
expectedDocuments: ['Document Visible to Everyone'],
},
{
user: outsideUser,
expectedDocuments: ['Document Visible to Admin with Recipient'],
},
];
for (const testCase of testCases) {
await apiSignin({
page,
email: testCase.user.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Check that the user sees the expected documents
for (const documentTitle of testCase.expectedDocuments) {
await expect(page.getByRole('link', { name: documentTitle, exact: true })).toBeVisible();
}
await apiSignout({ page });
}
});
test('[TEAMS]: ensure recipient can see document regardless of visibility', async ({ page }) => {
const team = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Seed a document with ADMIN visibility but make the member user a recipient
await seedDocuments([
{
sender: team.owner,
recipients: [memberUser],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document with Member Recipient',
},
},
]);
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Check that the member user can see the document
await expect(
page.getByRole('link', { name: 'Admin Document with Member Recipient', exact: true }),
).toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: check that members cannot see ADMIN-only documents', async ({ page }) => {
const team = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Seed an ADMIN-only document
await seedDocuments([
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Only Document',
},
},
]);
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Check that the member user cannot see the ADMIN-only document
await expect(
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: check that managers cannot see ADMIN-only documents', async ({ page }) => {
const team = await seedTeam();
// Seed a manager user
const managerUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
// Seed an ADMIN-only document
await seedDocuments([
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Only Document',
},
},
]);
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Check that the manager user cannot see the ADMIN-only document
await expect(
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: check that admin can see MANAGER_AND_ABOVE documents', async ({ page }) => {
const team = await seedTeam();
// Seed an admin user
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
// Seed a MANAGER_AND_ABOVE document
await seedDocuments([
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Manager and Above Document',
},
},
]);
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Check that the admin user can see the MANAGER_AND_ABOVE document
await expect(
page.getByRole('link', { name: 'Manager and Above Document', exact: true }),
).toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: users cannot see documents from other teams', async ({ page }) => {
// Seed two teams with documents
const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();
const { team: teamB, teamMember2: teamBMember } = await seedTeamDocuments();
// Seed a document in team B
await seedDocuments([
{
sender: teamB.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: teamB.id,
visibility: 'EVERYONE',
title: 'Team B Document',
},
},
]);
// Sign in as a member of team A
await apiSignin({
page,
email: teamAMember.email,
redirectPath: `/t/${teamA.url}/documents?status=COMPLETED`,
});
// Verify that the user cannot see the document from team B
await expect(page.getByRole('link', { name: 'Team B Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: personal documents are not visible in team context', async ({ page }) => {
// Seed a team and a user with personal documents
const { team, teamMember2 } = await seedTeamDocuments();
const personalUser = await seedUser();
// Seed a personal document for teamMember2
await seedDocuments([
{
sender: teamMember2,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: null, // Indicates a personal document
visibility: 'EVERYONE',
title: 'Personal Document',
},
},
]);
// Sign in as teamMember2 in the team context
await apiSignin({
page,
email: teamMember2.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Verify that the personal document is not visible in the team context
await expect(
page.getByRole('link', { name: 'Personal Document', exact: true }),
).not.toBeVisible();
await apiSignout({ page });
});
test('[PERSONAL]: team documents are not visible in personal account', async ({ page }) => {
// Seed a team and a user with personal documents
const { team, teamMember2 } = await seedTeamDocuments();
// Seed a team document
await seedDocuments([
{
sender: teamMember2,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Team Document',
},
},
]);
// Sign in as teamMember2 in the personal context
await apiSignin({
page,
email: teamMember2.email,
redirectPath: `/documents?status=COMPLETED`,
});
// Verify that the team document is not visible in the personal context
await expect(page.getByRole('link', { name: 'Team Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
@@ -42,8 +42,10 @@ test.describe('[EE_ONLY]', () => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').check();
@@ -92,8 +94,8 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
@@ -76,8 +76,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
@@ -211,8 +211,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
@@ -2,7 +2,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { isDeepEqual } from 'remeda';
import { equals } from 'remeda';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
@@ -42,7 +42,7 @@ export const LimitsProvider = ({
const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => {
if (isDeepEqual(oldLimits, newLimits)) {
if (equals(oldLimits, newLimits)) {
return oldLimits;
}
@@ -1,78 +0,0 @@
import config from '@documenso/tailwind-config';
import {
Body,
Container,
Head,
Hr,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from '../components';
import type { TemplateDocumentCancelProps } from '../template-components/template-document-cancel';
import TemplateDocumentImage from '../template-components/template-document-image';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentCancelEmailTemplateProps = Partial<TemplateDocumentCancelProps>;
export const RecipientRemovedFromDocumentTemplate = ({
inviterName = 'Lucas Smith',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentCancelEmailTemplateProps) => {
const previewText = `${inviterName} has removed you from the document ${documentName}.`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has removed you from the document
<br />"{documentName}"
</Text>
</Section>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default RecipientRemovedFromDocumentTemplate;
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { Field } from '@documenso/prisma/client';
import { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
@@ -3,19 +3,19 @@ import { useCallback, useRef, useState } from 'react';
type ThrottleOptions = {
leading?: boolean;
trailing?: boolean;
};
}
export function useThrottleFn<T extends (...args: unknown[]) => unknown>(
fn: T,
ms = 500,
options: ThrottleOptions = {},
options: ThrottleOptions = {}
): [(...args: Parameters<T>) => void, boolean, () => void] {
const [isThrottling, setIsThrottling] = useState(false);
const $isThrottling = useRef(false);
const $timeout = useRef<NodeJS.Timeout | null>(null);
const $lastArgs = useRef<Parameters<T> | null>(null);
const { leading = true, trailing = true } = options;
const $setIsThrottling = useCallback((value: boolean) => {
@@ -44,7 +44,7 @@ export function useThrottleFn<T extends (...args: unknown[]) => unknown>(
$lastArgs.current = args;
}
},
[fn, ms, leading, trailing, $setIsThrottling],
[fn, ms, leading, trailing, $setIsThrottling]
);
const cancel = useCallback(() => {
+5 -33
View File
@@ -1,19 +1,12 @@
import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
export enum RecipientStatusType {
COMPLETED = 'completed',
OPENED = 'opened',
WAITING = 'waiting',
UNSIGNED = 'unsigned',
}
export const getRecipientType = (recipient: Recipient) => {
if (
recipient.role === RecipientRole.CC ||
(recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
) {
return RecipientStatusType.COMPLETED;
return 'completed';
}
if (
@@ -21,33 +14,12 @@ export const getRecipientType = (recipient: Recipient) => {
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
return RecipientStatusType.OPENED;
return 'opened';
}
if (
recipient.sendStatus === SendStatus.SENT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
return RecipientStatusType.WAITING;
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED') {
return 'waiting';
}
return RecipientStatusType.UNSIGNED;
};
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {
return RecipientStatusType.UNSIGNED;
}
if (types.includes(RecipientStatusType.OPENED)) {
return RecipientStatusType.OPENED;
}
if (types.includes(RecipientStatusType.WAITING)) {
return RecipientStatusType.WAITING;
}
return RecipientStatusType.COMPLETED;
return 'unsigned';
};
+1 -2
View File
@@ -5,8 +5,7 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL');
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
process.env.NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? NEXT_PUBLIC_WEBAPP_URL();
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = process.env.NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? NEXT_PUBLIC_WEBAPP_URL();
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
@@ -1,23 +0,0 @@
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import type { TDocumentVisibility } from '../types/document-visibility';
type DocumentVisibilityTypeData = {
key: TDocumentVisibility;
value: string;
};
export const DOCUMENT_VISIBILITY: Record<string, DocumentVisibilityTypeData> = {
[DocumentVisibility.ADMIN]: {
key: DocumentVisibility.ADMIN,
value: 'Admins only',
},
[DocumentVisibility.EVERYONE]: {
key: DocumentVisibility.EVERYONE,
value: 'Everyone',
},
[DocumentVisibility.MANAGER_AND_ABOVE]: {
key: DocumentVisibility.MANAGER_AND_ABOVE,
value: 'Managers and above',
},
} satisfies Record<TDocumentVisibility, DocumentVisibilityTypeData>;
+1 -5
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr'] as const;
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en'] as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
@@ -38,8 +38,4 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
full: 'English',
short: 'en',
},
fr: {
full: 'French',
short: 'fr',
},
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
+1 -1
View File
@@ -48,7 +48,7 @@
"pg": "^8.11.3",
"playwright": "1.43.0",
"react": "^18",
"remeda": "^2.12.1",
"remeda": "^1.27.1",
"sharp": "0.32.6",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
@@ -7,7 +7,6 @@ import {
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentSigningOrder } from '@documenso/prisma/client';
export type CreateDocumentMetaOptions = {
documentId: number;
@@ -17,7 +16,6 @@ export type CreateDocumentMetaOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
signingOrder?: DocumentSigningOrder;
userId: number;
requestMetadata: RequestMetadata;
};
@@ -31,7 +29,6 @@ export const upsertDocumentMeta = async ({
password,
userId,
redirectUrl,
signingOrder,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -81,7 +78,6 @@ export const upsertDocumentMeta = async ({
timezone,
documentId,
redirectUrl,
signingOrder,
},
update: {
subject,
@@ -90,7 +86,6 @@ export const upsertDocumentMeta = async ({
dateFormat,
timezone,
redirectUrl,
signingOrder,
},
});
@@ -2,18 +2,11 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import {
DocumentSigningOrder,
DocumentStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email';
@@ -36,7 +29,6 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
},
},
include: {
documentMeta: true,
Recipient: {
where: {
token,
@@ -67,16 +59,6 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
if (!isRecipientsTurn) {
throw new Error(
`Recipient ${recipient.id} attempted to complete the document before it was their turn`,
);
}
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
@@ -138,48 +120,17 @@ export const completeDocumentWithToken = async ({
});
});
const pendingRecipients = await prisma.recipient.findMany({
select: {
id: true,
signingOrder: true,
},
const pendingRecipients = await prisma.recipient.count({
where: {
documentId: document.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
if (pendingRecipients.length > 0) {
if (pendingRecipients > 0) {
await sendPendingEmail({ documentId, recipientId: recipient.id });
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: { id: nextRecipient.id },
data: { sendStatus: SendStatus.SENT },
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: document.userId,
documentId: document.id,
recipientId: nextRecipient.id,
requestMetadata,
},
});
});
}
}
const haveAllRecipientsSigned = await prisma.document.findFirst({
@@ -187,7 +138,7 @@ export const completeDocumentWithToken = async ({
id: document.id,
Recipient: {
every: {
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
signingStatus: SigningStatus.SIGNED,
},
},
},
@@ -2,11 +2,10 @@ import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DocumentVisibility } from '../../types/document-visibility';
import type { FindResultSet } from '../../types/find-result-set';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
@@ -25,7 +24,6 @@ export type FindDocumentsOptions = {
};
period?: PeriodSelectorValue;
senderIds?: number[];
search?: string;
};
export const findDocuments = async ({
@@ -38,7 +36,6 @@ export const findDocuments = async ({
orderBy,
period,
senderIds,
search,
}: FindDocumentsOptions) => {
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
@@ -61,14 +58,6 @@ export const findDocuments = async ({
},
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
}
@@ -81,7 +70,6 @@ export const findDocuments = async ({
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const teamMemberRole = team?.members[0].role ?? null;
const termFilters = match(term)
.with(P.string.minLength(1), () => {
@@ -94,45 +82,7 @@ export const findDocuments = async ({
})
.otherwise(() => undefined);
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
const visibilityFilters = [
match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
{
Recipient: {
some: {
email: user.email,
},
},
},
];
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
}
const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user);
if (filters === null) {
return {
@@ -198,7 +148,9 @@ export const findDocuments = async ({
}
const whereClause: Prisma.DocumentWhereInput = {
AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }, { ...searchFilter }],
...termFilters,
...filters,
...deletedFilter,
};
if (period) {
@@ -381,7 +333,6 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[],
) => {
const teamEmail = team.teamEmail?.email ?? null;
@@ -392,7 +343,6 @@ const findTeamDocumentsFilter = (
OR: [
{
teamId: team.id,
OR: visibilityFilters,
},
],
};
@@ -408,7 +358,6 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
},
OR: visibilityFilters,
});
// Filter to display all documents that have been sent by the team email.
@@ -416,7 +365,6 @@ const findTeamDocumentsFilter = (
User: {
email: teamEmail,
},
OR: visibilityFilters,
});
}
@@ -441,7 +389,6 @@ const findTeamDocumentsFilter = (
},
},
},
OR: visibilityFilters,
};
})
.with(ExtendedDocumentStatus.DRAFT, () => {
@@ -450,7 +397,6 @@ const findTeamDocumentsFilter = (
{
teamId: team.id,
status: ExtendedDocumentStatus.DRAFT,
OR: visibilityFilters,
},
],
};
@@ -461,7 +407,6 @@ const findTeamDocumentsFilter = (
User: {
email: teamEmail,
},
OR: visibilityFilters,
});
}
@@ -473,7 +418,6 @@ const findTeamDocumentsFilter = (
{
teamId: team.id,
status: ExtendedDocumentStatus.PENDING,
OR: visibilityFilters,
},
],
};
@@ -492,13 +436,11 @@ const findTeamDocumentsFilter = (
},
},
},
OR: visibilityFilters,
},
{
User: {
email: teamEmail,
},
OR: visibilityFilters,
},
],
});
@@ -512,7 +454,6 @@ const findTeamDocumentsFilter = (
OR: [
{
teamId: team.id,
OR: visibilityFilters,
},
],
};
@@ -525,13 +466,11 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
},
OR: visibilityFilters,
},
{
User: {
email: teamEmail,
},
OR: visibilityFilters,
},
);
}
@@ -1,10 +1,6 @@
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { DocumentVisibility } from '../../types/document-visibility';
import { getTeamById } from '../team/get-team';
export type GetDocumentByIdOptions = {
@@ -32,11 +28,6 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
email: true,
},
},
Recipient: {
select: {
email: true,
},
},
team: {
select: {
id: true,
@@ -124,35 +115,5 @@ export const getDocumentWhereInput = async ({
);
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const visibilityFilters = [
...match(team.currentTeamMember?.role)
.with(TeamMemberRole.ADMIN, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
{ visibility: DocumentVisibility.ADMIN },
])
.with(TeamMemberRole.MANAGER, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
])
.otherwise(() => [{ visibility: DocumentVisibility.EVERYONE }]),
{
Recipient: {
some: {
email: user.email,
},
},
},
];
return {
...documentWhereInput,
OR: [...visibilityFilters],
};
return documentWhereInput;
};
+4 -83
View File
@@ -1,24 +1,19 @@
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { Prisma, User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DocumentVisibility } from '../../types/document-visibility';
export type GetStatsInput = {
user: User;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
search?: string;
};
export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@@ -32,14 +27,8 @@ export const getStats = async ({ user, period, search, ...options }: GetStatsInp
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
? getTeamCounts({
...options.team,
createdAt,
currentUserEmail: user.email,
userId: user.id,
search,
})
: getCounts({ user, createdAt, search }));
? getTeamCounts({ ...options.team, createdAt })
: getCounts({ user, createdAt }));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
@@ -79,18 +68,9 @@ export const getStats = async ({ user, period, search, ...options }: GetStatsInp
type GetCountsOption = {
user: User;
createdAt: Prisma.DocumentWhereInput['createdAt'];
search?: string;
};
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
return Promise.all([
// Owner counts.
prisma.document.groupBy({
@@ -103,7 +83,6 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter],
},
}),
// Not signed counts.
@@ -122,7 +101,6 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
createdAt,
AND: [searchFilter],
},
}),
// Has signed counts.
@@ -160,7 +138,6 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
],
AND: [searchFilter],
},
}),
]);
@@ -170,11 +147,7 @@ type GetTeamCountsOption = {
teamId: number;
teamEmail?: string;
senderIds?: number[];
currentUserEmail: string;
userId: number;
createdAt: Prisma.DocumentWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
@@ -189,14 +162,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}
: undefined;
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: options.search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
],
};
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
userId: userIdWhereClause,
createdAt,
@@ -207,50 +172,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
let notSignedCountsGroupByArgs = null;
let hasSignedCountsGroupByArgs = null;
const visibilityFilters = [
...match(options.currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
{ visibility: DocumentVisibility.ADMIN },
])
.with(TeamMemberRole.MANAGER, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
])
.otherwise(() => [{ visibility: DocumentVisibility.EVERYONE }]),
];
ownerCountsWhereInput = {
...ownerCountsWhereInput,
OR: [
{
AND: [
{
visibility: {
in: visibilityFilters.map((filter) => filter.visibility),
},
},
{
Recipient: {
none: {
email: options.currentUserEmail,
},
},
},
],
},
{
Recipient: {
some: {
email: options.currentUserEmail,
},
},
},
],
...searchFilter,
};
if (teamEmail) {
ownerCountsWhereInput = {
userId: userIdWhereClause,
@@ -3,13 +3,7 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import {
DocumentSigningOrder,
DocumentStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
@@ -63,9 +57,7 @@ export const sendDocument = async ({
}),
},
include: {
Recipient: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
Recipient: true,
documentMeta: true,
documentData: true,
},
@@ -83,21 +75,6 @@ export const sendDocument = async ({
throw new Error('Can not send completed document');
}
const signingOrder = document.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
let recipientsToNotify = document.Recipient;
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
// Get the currently active recipient.
recipientsToNotify = document.Recipient.filter(
(r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC,
).slice(0, 1);
// Secondary filter so we aren't resending if the current active recipient has already
// received the document.
recipientsToNotify.filter((r) => r.sendStatus !== SendStatus.SENT);
}
const { documentData } = document;
if (!documentData.data) {
@@ -158,7 +135,7 @@ export const sendDocument = async ({
if (sendEmail) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
@@ -6,7 +6,6 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -20,7 +19,6 @@ export type UpdateDocumentSettingsOptions = {
data: {
title?: string;
externalId?: string | null;
visibility?: string | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
@@ -93,14 +91,10 @@ export const updateDocumentSettings = async ({
}
}
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;
const isTitleSame = data.title === document.title;
const isExternalIdSame = data.externalId === document.externalId;
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
@@ -171,21 +165,6 @@ export const updateDocumentSettings = async ({
);
}
if (!isDocumentVisibilitySame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.visibility,
to: data.visibility || '',
},
}),
);
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
return document;
@@ -203,8 +182,7 @@ export const updateDocumentSettings = async ({
},
data: {
title: data.title,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
externalId: data.externalId || null,
authOptions,
},
});
@@ -1,5 +1,3 @@
import { isDeepEqual } from 'remeda';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@@ -22,15 +20,22 @@ import {
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Field } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions {
userId: number;
documentId: number;
fields: FieldData[];
fields: {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
}[];
requestMetadata?: RequestMetadata;
}
@@ -58,9 +63,6 @@ export const setFieldsForDocument = async ({
},
],
},
include: {
Recipient: true,
},
});
const user = await prisma.user.findFirstOrThrow({
@@ -95,36 +97,21 @@ export const setFieldsForDocument = async ({
(existingField) => !fields.find((field) => field.id === existingField.id),
);
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const linkedFields = fields
.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const recipient = document.Recipient.find(
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, `Recipient not found for field ${field.id}`);
}
// Check whether the existing field can be modified.
if (
existing &&
hasFieldBeenChanged(existing, field) &&
!canRecipientFieldsBeModified(recipient, existingFields)
) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Cannot modify a field where the recipient has already interacted with the document',
return {
...field,
_persisted: existing,
};
})
.filter((field) => {
return (
field._persisted?.Recipient?.sendStatus !== SendStatus.SENT &&
field._persisted?.Recipient?.signingStatus !== SigningStatus.SIGNED
);
}
return {
...field,
_persisted: existing,
_recipient: recipient,
};
});
});
const persistedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
@@ -335,33 +322,3 @@ export const setFieldsForDocument = async ({
return [...filteredFields, ...persistedFields];
};
/**
* If you change this you MUST update the `hasFieldBeenChanged` function.
*/
type FieldData = {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
};
const hasFieldBeenChanged = (field: Field, newFieldData: FieldData) => {
const currentFieldMeta = field.fieldMeta || null;
const newFieldMeta = newFieldData.fieldMeta || null;
return (
field.type !== newFieldData.type ||
field.page !== newFieldData.pageNumber ||
field.positionX.toNumber() !== newFieldData.pageX ||
field.positionY.toNumber() !== newFieldData.pageY ||
field.width.toNumber() !== newFieldData.pageWidth ||
field.height.toNumber() !== newFieldData.pageHeight ||
!isDeepEqual(currentFieldMeta, newFieldMeta)
);
};
@@ -4,8 +4,5 @@ import { cookies } from 'next/headers';
// eslint-disable-next-line @typescript-eslint/require-await
export const switchI18NLanguage = async (lang: string) => {
// Two year expiry.
const maxAge = 60 * 60 * 24 * 365 * 2;
cookies().set('language', lang, { maxAge });
cookies().set('language', lang);
};

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