mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
chore: merged main
This commit is contained in:
@ -4,8 +4,10 @@ NEXTAUTH_SECRET="secret"
|
|||||||
|
|
||||||
# [[CRYPTO]]
|
# [[CRYPTO]]
|
||||||
# Application Key for symmetric encryption and decryption
|
# Application Key for symmetric encryption and decryption
|
||||||
# This should be a random string of at least 32 characters
|
# REQUIRED: This should be a random string of at least 32 characters
|
||||||
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
NEXT_PRIVATE_ENCRYPTION_KEY=""
|
||||||
|
# REQUIRED: This should be a random string of at least 32 characters
|
||||||
|
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=""
|
||||||
|
|
||||||
# [[AUTH OPTIONAL]]
|
# [[AUTH OPTIONAL]]
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
|
|||||||
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -33,3 +33,4 @@ body:
|
|||||||
- label: I have explained the use case or scenario for this feature.
|
- label: I have explained the use case or scenario for this feature.
|
||||||
- label: I have included any relevant technical details or design suggestions.
|
- label: I have included any relevant technical details or design suggestions.
|
||||||
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||||
|
- label: I want to work on creating a PR for this issue if approved
|
||||||
|
|||||||
56
.github/ISSUE_TEMPLATE/improvement.yml
vendored
56
.github/ISSUE_TEMPLATE/improvement.yml
vendored
@ -1,35 +1,39 @@
|
|||||||
name: 'General Improvement'
|
name: 'General Improvement Request'
|
||||||
description: Suggest a minor enhancement or improvement for this project
|
description: 'Suggest a minor enhancement or improvement for this project'
|
||||||
|
title: '[Title for your improvement suggestion]'
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: Please provide a clear and concise title for your improvement suggestion
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Improvement Description
|
label: 'Describe the improvement you are suggesting in detail'
|
||||||
description: Describe the improvement you are suggesting in detail. Explain what specific aspect of the project it addresses or enhances.
|
description: 'Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Rationale
|
label: 'Additional Information & Alternatives (optional)'
|
||||||
description: Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.
|
description: 'Are there any additional context or information that might be relevant to the improvement suggestion.'
|
||||||
- type: textarea
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
id: assignee
|
||||||
attributes:
|
attributes:
|
||||||
label: Proposed Solution
|
label: 'Do you want to work on this improvement?'
|
||||||
description: If you have a suggestion for how this improvement could be implemented, describe it here. Include any technical details, design suggestions, or other relevant information.
|
multiple: false
|
||||||
- type: textarea
|
options:
|
||||||
attributes:
|
- 'No'
|
||||||
label: Alternatives (optional)
|
- 'Yes'
|
||||||
description: Are there any alternative approaches to achieve the same improvement? Describe other ways to address the issue or enhance the project.
|
default: 0
|
||||||
- type: textarea
|
validations:
|
||||||
attributes:
|
required: true
|
||||||
label: Additional Context
|
|
||||||
description: Add any additional context or information that might be relevant to the improvement suggestion.
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Please check the boxes that apply to this improvement suggestion.
|
label: 'Please check the boxes that apply to this improvement suggestion.'
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing issues and improvement suggestions to avoid duplication.
|
- label: 'I have searched the existing issues and improvement suggestions to avoid duplication.'
|
||||||
- label: I have provided a clear description of the improvement being suggested.
|
- label: 'I have provided a clear description of the improvement being suggested.'
|
||||||
- label: I have explained the rationale behind this improvement.
|
- label: 'I have explained the rationale behind this improvement.'
|
||||||
- label: I have included any relevant technical details or design suggestions.
|
- label: 'I have included any relevant technical details or design suggestions.'
|
||||||
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
- label: 'I understand that this is a suggestion and that there is no guarantee of implementation.'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty
|
||||||
|
|
||||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||||
|
|
||||||
<p align="center" style="margin-top: 20px">
|
<p align="center" style="margin-top: 20px">
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Jan 10th Email Provider Security Incident
|
||||||
|
description: On January 10th, 2022, we were notified by our email provider that they had experienced a security incident.
|
||||||
|
authorName: 'Lucas Smith'
|
||||||
|
authorImage: '/blog/blog-author-lucas.png'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2024-01-17
|
||||||
|
tags:
|
||||||
|
- Security
|
||||||
|
---
|
||||||
|
|
||||||
|
On January 10th, 2024 we were notified by our email provider that a security incident had occurred. This security incident which had started on January 7th led to a bad actor obtaining access to their database which contains ours and other customer’s data.
|
||||||
|
|
||||||
|
We understand that during this security incident the following has been accessed:
|
||||||
|
|
||||||
|
- Email addresses.
|
||||||
|
- Metadata on emails sent excluding the email body.
|
||||||
|
|
||||||
|
While the incident is unfortunate we are pleased with the remediation and the processes that our email provider has put in place to help avoid this kind of situation in the future. Since the incident, our provider has rectified the issue and has engaged a security company to conduct an exhaustive investigation and to help improve their security posture moving forward.
|
||||||
|
|
||||||
|
We remain steadfast in our commitment to our current email provider, and will not be taking any further action with relation to changing providers.
|
||||||
|
|
||||||
|
We are now working with our legal counsel to ensure that we provide the appropriate notice to all our customers in each jurisdiction. If you have any further questions on this incident please feel free to contact our support team at [support@documenso.com](mailto:support@documenso.com).
|
||||||
|
|
||||||
|
We appreciate your ongoing support in this matter.
|
||||||
|
|
||||||
|
You can read more on the incident on our providers blog post below:
|
||||||
|
[https://resend.com/blog/incident-report-for-january-10-2024](https://resend.com/blog/incident-report-for-january-10-2024)
|
||||||
115
apps/marketing/content/blog/linear-gh.mdx
Normal file
115
apps/marketing/content/blog/linear-gh.mdx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
title: Moving from Linear to GitHub & LIVE Roadmap 2.0
|
||||||
|
description: We are leaving linear and are going all in on GitHub. Here is how we do it.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2024-01-10
|
||||||
|
Tags:
|
||||||
|
- GitHub
|
||||||
|
- Backlog
|
||||||
|
- Roadmap
|
||||||
|
---
|
||||||
|
|
||||||
|
# From Linear to GitHub
|
||||||
|
|
||||||
|
> TLDR; We are leaving Linear and are using only GitHub going forward. We no longer communicate feature timelines, only what we are working on and what's next.
|
||||||
|
|
||||||
|
If you follow us, you know we have been in full-on build mode. We are building, the community is building, it's great. Building is our daily business, so we think a lot about improving our approach to doing it.
|
||||||
|
Our most recent approach is to reduce the number of tools and platforms we use. Every tool we use
|
||||||
|
|
||||||
|
- Reduces the average time you spend on the tool
|
||||||
|
- Reduces your focus
|
||||||
|
- Increases mental load to keep all points of interest in mind
|
||||||
|
|
||||||
|
We thought about where we spend the most time, and hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because:
|
||||||
|
|
||||||
|
- It's where the community contributes, and we are all about community
|
||||||
|
- It's where we show the world what we are working on
|
||||||
|
|
||||||
|
# The old structure
|
||||||
|
|
||||||
|
So far, we have been using Linear for our Backlog/ Task Management and synced issues we want to showcase or work on with the community via synclinear.com. Not only did we have our development issues there, but since
|
||||||
|
we have our own resident founding designer, we created a proper design backlog to structure our design workflows.
|
||||||
|
|
||||||
|
# The new structure
|
||||||
|
|
||||||
|
We moved everything to GitHub once we realized our focus was already there. This has a few key benefits:
|
||||||
|
|
||||||
|
- Reducing dilution of attention and time: You can hang out on GitHub without risk of missing much
|
||||||
|
- Putting different aspects of Documenso close to each other: Development, Design, Community
|
||||||
|
- Keep long-term, niche, and very abstract issues out of the main repo so we don't get desensitized by large issue numbers
|
||||||
|
|
||||||
|
To achieve this, we created a few GitHub repositories to host issues, with the main repository remaining the central point of interest, especially for the community.
|
||||||
|
|
||||||
|
## 1. Main Repository - Day to day Issues and the shorter-term roadmap (LIVE Roadmap 2.0)
|
||||||
|
|
||||||
|
> [github.com/documenso/documenso](https://github.com/documenso/documenso)
|
||||||
|
|
||||||
|
Apart from the source code of the Documenso app and website, the main repo houses issues raised by the community and issues where we invite the community to participate.
|
||||||
|
With the overhauling of our issue management, we are also updating our progress communication. While the software and product development process is highly complex,
|
||||||
|
we try to give as much insight into what we do as possible. To that end, we went through 3 phases, three being what we do now.
|
||||||
|
|
||||||
|
1. **One extensive roadmap**: Initially we had one roadmap and were (very) slowly checking off boxes there (via a "Roadmap" milestone). While this is easy, it's also pretty imprecise and not practical as the project grows
|
||||||
|
2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter; a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel,
|
||||||
|
like working with the community and tuning things you go. Hitting time targets is tricky because there may be better things to do than sticking to that time target. This is always much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in a vacuum.
|
||||||
|
3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the live roadmap [https://documen.so/live](https://documen.so/live). It now shows what we are currently working on and what we plan on doing next. We do not provide
|
||||||
|
a specific timeline anymore since we couldn't even if we wanted to. Of course, we set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible.
|
||||||
|
|
||||||
|
## 2. Public Backlog - The longer-term roadmap
|
||||||
|
|
||||||
|
> [github.com/documenso/backlog](https://github.com/documenso/backlog)
|
||||||
|
|
||||||
|
The public backlog houses everything we want to build eventually. We do not provide a specific timeline of when that might happen. If we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. This helps us gauge interest in specific features.
|
||||||
|
**Issues in the public backlog are not** available to be worked on. For issues to work on, please check the main repository issues. The issues found here are scoped broader since they are not meant for immediate execution but rather give a sense of where Documenso is going and what we consider part of our domain.
|
||||||
|
|
||||||
|
## 3. Internal Backlog
|
||||||
|
|
||||||
|
> github.com/documenso/backlog-internal
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/gh1.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="GitHub: Development Board"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
Our internal Kanban for development
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
This serves as the direct replacement for our Linear backlog. Here, we manage issues that are either too small or short-term for inclusion in the long-term roadmap, yet too specialized or fundamental to be integrated into the main repository. Our development Kanban board is implemented using a GitHub project.
|
||||||
|
|
||||||
|
## 4. Internal Design Backlog
|
||||||
|
|
||||||
|
> github.com/documenso/design-internal
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/gh2.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="GitHub: Design Board"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
Our internal Kanban for design
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
This is the design equivalent of the internal backlog. The internal design backlog houses our design projects that include the exploration of new features, detailed UI designs, and improving the platform overall.
|
||||||
|
It's similar to the Kanban board for the development backlog.
|
||||||
|
|
||||||
|
## 5. Public Design Repository
|
||||||
|
|
||||||
|
> [github.com/documenso/backlog-design](https://github.com/documenso/design)
|
||||||
|
|
||||||
|
While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead.
|
||||||
|
We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live).
|
||||||
|
|
||||||
|
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
|
||||||
|
|
||||||
|
Best from Hamburg\
|
||||||
|
Timur
|
||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Announcing Pre-Seed and Open Metrics
|
title: Announcing Pre-Seed and Open Metrics
|
||||||
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
||||||
authorName: 'Timur Ercan'
|
authorName: 'Timur Ercan'
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
|
|||||||
@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
|
|||||||
|
|
||||||
## Documenso Merch Shop
|
## Documenso Merch Shop
|
||||||
|
|
||||||
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso.
|
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<MdxNextImage
|
<MdxNextImage
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/gh1.png
Normal file
BIN
apps/marketing/public/blog/gh1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
BIN
apps/marketing/public/blog/gh2.png
Normal file
BIN
apps/marketing/public/blog/gh2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@ -47,6 +47,14 @@ export const TEAM_MEMBERS = [
|
|||||||
engagement: 'Full-Time',
|
engagement: 'Full-Time',
|
||||||
joinDate: 'October 9th, 2023',
|
joinDate: 'October 9th, 2023',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Adithya Krishna',
|
||||||
|
role: 'Software Engineer - II',
|
||||||
|
salary: '-',
|
||||||
|
location: 'India',
|
||||||
|
engagement: 'Full-Time',
|
||||||
|
joinDate: 'December 1st, 2023',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FUNDING_RAISED = [
|
export const FUNDING_RAISED = [
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -29,6 +29,7 @@ export type EditDocumentFormProps = {
|
|||||||
user: User;
|
user: User;
|
||||||
document: DocumentWithData;
|
document: DocumentWithData;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
|
documentMeta: DocumentMeta | null;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
};
|
};
|
||||||
@ -41,6 +42,7 @@ export const EditDocumentForm = ({
|
|||||||
document,
|
document,
|
||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
|
documentMeta,
|
||||||
user: _user,
|
user: _user,
|
||||||
documentData,
|
documentData,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
@ -56,6 +58,8 @@ export const EditDocumentForm = ({
|
|||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
||||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
||||||
|
const { mutateAsync: setPasswordForDocument } =
|
||||||
|
trpc.document.setPasswordForDocument.useMutation();
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
title: {
|
title: {
|
||||||
@ -176,6 +180,13 @@ export const EditDocumentForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPasswordSubmit = async (password: string) => {
|
||||||
|
await setPasswordForDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -185,7 +196,13 @@ export const EditDocumentForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
<LazyPDFViewer
|
||||||
|
key={documentData.id}
|
||||||
|
documentData={documentData}
|
||||||
|
document={document}
|
||||||
|
password={documentMeta?.password}
|
||||||
|
onPasswordSubmit={onPasswordSubmit}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
@ -40,7 +42,24 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
redirect('/documents');
|
redirect('/documents');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { documentData } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
if (documentMeta?.password) {
|
||||||
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const securePassword = Buffer.from(
|
||||||
|
symmetricDecrypt({
|
||||||
|
key,
|
||||||
|
data: documentMeta.password,
|
||||||
|
}),
|
||||||
|
).toString('utf-8');
|
||||||
|
|
||||||
|
documentMeta.password = securePassword;
|
||||||
|
}
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
const [recipients, fields] = await Promise.all([
|
||||||
getRecipientsForDocument({
|
getRecipientsForDocument({
|
||||||
@ -83,6 +102,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
className="mt-8"
|
className="mt-8"
|
||||||
document={document}
|
document={document}
|
||||||
user={user}
|
user={user}
|
||||||
|
documentMeta={documentMeta}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
documentData={documentData}
|
documentData={documentData}
|
||||||
@ -91,7 +111,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
<LazyPDFViewer
|
||||||
|
document={document}
|
||||||
|
key={documentData.id}
|
||||||
|
documentMeta={documentMeta}
|
||||||
|
documentData={documentData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Download, Edit, Pencil } from 'lucide-react';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
@ -55,28 +55,14 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
const documentData = document?.documentData;
|
const documentData = document?.documentData;
|
||||||
|
|
||||||
if (!documentData) {
|
if (!documentData) {
|
||||||
return;
|
throw Error('No document available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentBytes = await getFile(documentData);
|
await downloadPDF({ documentData, fileName: row.title });
|
||||||
|
} catch (err) {
|
||||||
const blob = new Blob([documentBytes], {
|
|
||||||
type: 'application/pdf',
|
|
||||||
});
|
|
||||||
|
|
||||||
const link = window.document.createElement('a');
|
|
||||||
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
|
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
|
||||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
|
||||||
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
window.URL.revokeObjectURL(link.href);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
description: 'An error occurred while trying to download file.',
|
description: 'An error occurred while downloading your document.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||||
@ -44,6 +45,7 @@ export type DataTableActionDropdownProps = {
|
|||||||
|
|
||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
@ -63,6 +65,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
const isDocumentDeletable = isOwner;
|
const isDocumentDeletable = isOwner;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
|
try {
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
@ -81,21 +84,14 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentBytes = await getFile(documentData);
|
await downloadPDF({ documentData, fileName: row.title });
|
||||||
|
} catch (err) {
|
||||||
const blob = new Blob([documentBytes], {
|
toast({
|
||||||
type: 'application/pdf',
|
title: 'Something went wrong',
|
||||||
|
description: 'An error occurred while downloading your document.',
|
||||||
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const link = window.document.createElement('a');
|
|
||||||
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
|
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
|
||||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
|
||||||
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
window.URL.revokeObjectURL(link.href);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
<DocumentStatus status={value} />
|
<DocumentStatus status={value} />
|
||||||
|
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
<span className="ml-1 inline-block opacity-50">
|
||||||
{Math.min(stats[value], 99)}
|
{Math.min(stats[value], 99)}
|
||||||
{stats[value] > 99 && '+'}
|
{stats[value] > 99 && '+'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||||
@ -17,6 +18,8 @@ export default async function SecuritySettingsPage() {
|
|||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
{user.identityProvider === 'DOCUMENSO' ? (
|
||||||
|
<div>
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
<PasswordForm user={user} className="max-w-xl" />
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
<hr className="mb-4 mt-8" />
|
||||||
@ -24,8 +27,8 @@ export default async function SecuritySettingsPage() {
|
|||||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Add and manage your two factor security settings to add an extra layer of security to your
|
Add and manage your two factor security settings to add an extra layer of security to
|
||||||
account!
|
your account!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 max-w-xl">
|
<div className="mt-4 max-w-xl">
|
||||||
@ -42,5 +45,18 @@ export default async function SecuritySettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-medium">
|
||||||
|
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
To update your password, enable two-factor authentication, and manage other security
|
||||||
|
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
||||||
|
settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { FileSearch } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
|
import type { ButtonProps } from '@documenso/ui/primitives/button';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type DocumentPreviewButtonProps = {
|
||||||
|
className?: string;
|
||||||
|
documentData: DocumentData;
|
||||||
|
} & ButtonProps;
|
||||||
|
|
||||||
|
export const DocumentPreviewButton = ({
|
||||||
|
className,
|
||||||
|
documentData,
|
||||||
|
...props
|
||||||
|
}: DocumentPreviewButtonProps) => {
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className={className}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDialog((visible) => !visible)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
|
||||||
|
View Document
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DocumentDialog documentData={documentData} open={showDialog} onOpenChange={setShowDialog} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx
Normal file
17
apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
|
|
||||||
|
export type SigningLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SigningLayout({ children }: SigningLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<RefreshOnFocus />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,6 +17,8 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
|||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { DocumentPreviewButton } from './document-preview-button';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
token?: string;
|
token?: string;
|
||||||
@ -117,12 +119,20 @@ export default async function CompletedSigningPage({
|
|||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
|
{document.status === DocumentStatus.COMPLETED ? (
|
||||||
<DocumentDownloadButton
|
<DocumentDownloadButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
fileName={document.title}
|
fileName={document.title}
|
||||||
documentData={documentData}
|
documentData={documentData}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentPreviewButton
|
||||||
|
className="flex-1"
|
||||||
|
title="Signatures will appear once the document has been completed"
|
||||||
|
documentData={documentData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
@ -12,6 +13,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
|
|||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
@ -66,6 +68,23 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
redirect(`/sign/${token}/complete`);
|
redirect(`/sign/${token}/complete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (documentMeta?.password) {
|
||||||
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const securePassword = Buffer.from(
|
||||||
|
symmetricDecrypt({
|
||||||
|
key,
|
||||||
|
data: documentMeta.password,
|
||||||
|
}),
|
||||||
|
).toString('utf-8');
|
||||||
|
|
||||||
|
documentMeta.password = securePassword;
|
||||||
|
}
|
||||||
|
|
||||||
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
|
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
|
||||||
|
|
||||||
if (document.deletedAt) {
|
if (document.deletedAt) {
|
||||||
@ -101,7 +120,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
<LazyPDFViewer
|
||||||
|
key={documentData.id}
|
||||||
|
documentData={documentData}
|
||||||
|
document={document}
|
||||||
|
password={documentMeta?.password}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
@ -11,7 +13,7 @@ export default function SignInPage() {
|
|||||||
Welcome back, we are lucky to have you.
|
Welcome back, we are lucky to have you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignInForm className="mt-4" />
|
<SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
@ -17,7 +19,7 @@ export default function SignUpPage() {
|
|||||||
signing is within your grasp.
|
signing is within your grasp.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignUpForm className="mt-4" />
|
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to
|
|||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
@ -19,6 +20,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const onRecipientClick = () => {
|
const onRecipientClick = () => {
|
||||||
|
if (!recipient.token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
|
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied to clipboard',
|
title: 'Copied to clipboard',
|
||||||
@ -28,19 +33,22 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-1 flex cursor-pointer items-center gap-2" onClick={onRecipientClick}>
|
<div
|
||||||
|
className={cn('my-1 flex items-center gap-2', {
|
||||||
|
'cursor-pointer hover:underline': recipient.token,
|
||||||
|
})}
|
||||||
|
role={recipient.token ? 'button' : undefined}
|
||||||
|
title={recipient.token && 'Click to copy signing link for sending to recipient'}
|
||||||
|
onClick={onRecipientClick}
|
||||||
|
>
|
||||||
<StackAvatar
|
<StackAvatar
|
||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
className="text-muted-foreground text-sm hover:underline"
|
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||||
title="Click to copy signing link for sending to recipient"
|
|
||||||
>
|
|
||||||
{recipient.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ import {
|
|||||||
SETTINGS_PAGE_SHORTCUT,
|
SETTINGS_PAGE_SHORTCUT,
|
||||||
TEMPLATES_PAGE_SHORTCUT,
|
TEMPLATES_PAGE_SHORTCUT,
|
||||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||||
|
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@ -65,6 +67,8 @@ export type CommandMenuProps = {
|
|||||||
|
|
||||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(() => open ?? false);
|
const [isOpen, setIsOpen] = useState(() => open ?? false);
|
||||||
@ -81,6 +85,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isOwner = useCallback(
|
||||||
|
(document: Document) => document.userId === session?.user.id,
|
||||||
|
[session?.user.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSigningLink = useCallback(
|
||||||
|
(recipients: Recipient[]) =>
|
||||||
|
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
|
||||||
|
[session?.user.email],
|
||||||
|
);
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
if (!searchDocumentsData) {
|
if (!searchDocumentsData) {
|
||||||
return [];
|
return [];
|
||||||
@ -88,15 +103,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
|
|
||||||
return searchDocumentsData.map((document) => ({
|
return searchDocumentsData.map((document) => ({
|
||||||
label: document.title,
|
label: document.title,
|
||||||
path: `/documents/${document.id}`,
|
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
|
||||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||||
}));
|
}));
|
||||||
}, [searchDocumentsData]);
|
}, [searchDocumentsData, isOwner, getSigningLink]);
|
||||||
|
|
||||||
const currentPage = pages[pages.length - 1];
|
const currentPage = pages[pages.length - 1];
|
||||||
|
|
||||||
const toggleOpen = (e: KeyboardEvent) => {
|
const toggleOpen = () => {
|
||||||
e.preventDefault();
|
|
||||||
setIsOpen((isOpen) => !isOpen);
|
setIsOpen((isOpen) => !isOpen);
|
||||||
onOpenChange?.(!isOpen);
|
onOpenChange?.(!isOpen);
|
||||||
|
|
||||||
@ -136,7 +150,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
||||||
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
|
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
|
||||||
|
|
||||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen);
|
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
|
||||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||||
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||||
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
|
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
|
||||||
@ -238,7 +252,11 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
return THEMES.map((theme) => (
|
return THEMES.map((theme) => (
|
||||||
<CommandItem key={theme.theme} onSelect={() => setTheme(theme.theme)}>
|
<CommandItem
|
||||||
|
key={theme.theme}
|
||||||
|
onSelect={() => setTheme(theme.theme)}
|
||||||
|
className="mx-2 first:mt-2 last:mb-2"
|
||||||
|
>
|
||||||
<theme.icon className="mr-2" />
|
<theme.icon className="mr-2" />
|
||||||
{theme.label}
|
{theme.label}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@ -141,7 +141,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
<Link
|
||||||
|
href="https://github.com/documenso/documenso"
|
||||||
|
className="cursor-pointer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<LuGithub className="mr-2 h-4 w-4" />
|
<LuGithub className="mr-2 h-4 w-4" />
|
||||||
Star on Github
|
Star on Github
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
import { ONE_SECOND } from '@documenso/lib/constants/time';
|
import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -65,7 +65,7 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
|||||||
if (emailVerificationDialogLastShown) {
|
if (emailVerificationDialogLastShown) {
|
||||||
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||||
|
|
||||||
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
|
if (Date.now() - lastShownTimestamp < ONE_DAY) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -112,7 +112,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="signature"
|
name="signature"
|
||||||
@ -122,7 +121,10 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
containerClassName="rounded-lg border bg-background"
|
containerClassName={cn(
|
||||||
|
'rounded-lg border bg-background',
|
||||||
|
isSubmitting ? 'pointer-events-none opacity-50' : null,
|
||||||
|
)}
|
||||||
defaultValue={user.signature ?? undefined}
|
defaultValue={user.signature ?? undefined}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -53,9 +53,10 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
|||||||
|
|
||||||
export type SignInFormProps = {
|
export type SignInFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isGoogleSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -220,6 +221,8 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isGoogleSSOEnabled && (
|
||||||
|
<>
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
||||||
@ -229,7 +232,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant={'outline'}
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithGoogleClick}
|
onClick={onSignInWithGoogleClick}
|
||||||
@ -237,7 +240,10 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isTwoFactorAuthenticationDialogOpen}
|
open={isTwoFactorAuthenticationDialogOpen}
|
||||||
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
@ -24,6 +25,8 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
const SIGN_UP_REDIRECT_PATH = '/documents';
|
||||||
|
|
||||||
export const ZSignUpFormSchema = z.object({
|
export const ZSignUpFormSchema = z.object({
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
@ -38,9 +41,10 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
|||||||
|
|
||||||
export type SignUpFormProps = {
|
export type SignUpFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isGoogleSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -93,6 +97,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignUpWithGoogleClick = async () => {
|
||||||
|
try {
|
||||||
|
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@ -170,6 +187,28 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
>
|
>
|
||||||
{isSubmitting ? 'Signing up...' : 'Sign Up'}
|
{isSubmitting ? 'Signing up...' : 'Sign Up'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isGoogleSSOEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
<span className="text-muted-foreground bg-transparent">Or</span>
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant={'outline'}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignUpWithGoogleClick}
|
||||||
|
>
|
||||||
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
|
Sign Up with Google
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -23,7 +23,8 @@ services:
|
|||||||
- database
|
- database
|
||||||
- inbucket
|
- inbucket
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
- NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
||||||
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
||||||
- NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
- NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||||
- NEXTAUTH_SECRET=my-super-secure-secret
|
- NEXTAUTH_SECRET=my-super-secure-secret
|
||||||
- NEXTAUTH_URL=http://localhost:3000
|
- NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|||||||
3
package-lock.json
generated
3
package-lock.json
generated
@ -19869,7 +19869,8 @@
|
|||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"tailwind-merge": "^1.12.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"tailwindcss-animate": "^1.0.5",
|
"tailwindcss-animate": "^1.0.5",
|
||||||
"ts-pattern": "^5.0.5"
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
|||||||
{isDocument && (
|
{isDocument && (
|
||||||
<Text className="my-4 text-base text-slate-400">
|
<Text className="my-4 text-base text-slate-400">
|
||||||
This document was sent using{' '}
|
This document was sent using{' '}
|
||||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
|
||||||
Documenso.
|
Documenso.
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
29
packages/lib/client-only/download-pdf.ts
Normal file
29
packages/lib/client-only/download-pdf.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { getFile } from '../universal/upload/get-file';
|
||||||
|
|
||||||
|
type DownloadPDFProps = {
|
||||||
|
documentData: DocumentData;
|
||||||
|
fileName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => {
|
||||||
|
const bytes = await getFile(documentData);
|
||||||
|
|
||||||
|
const blob = new Blob([bytes], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = window.document.createElement('a');
|
||||||
|
|
||||||
|
const [baseTitle] = fileName?.includes('.pdf')
|
||||||
|
? fileName.split('.pdf')
|
||||||
|
: [fileName ?? 'document'];
|
||||||
|
|
||||||
|
link.href = window.URL.createObjectURL(blob);
|
||||||
|
link.download = `${baseTitle}_signed.pdf`;
|
||||||
|
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
window.URL.revokeObjectURL(link.href);
|
||||||
|
};
|
||||||
@ -1 +1,12 @@
|
|||||||
|
import { IdentityProvider } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const SALT_ROUNDS = 12;
|
export const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
|
||||||
|
[IdentityProvider.DOCUMENSO]: 'Documenso',
|
||||||
|
[IdentityProvider.GOOGLE]: 'Google',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
||||||
|
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
|
||||||
|
);
|
||||||
|
|||||||
@ -1 +1,23 @@
|
|||||||
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
|
||||||
|
|
||||||
|
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
|
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
|
throw new Error(
|
||||||
|
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
|
||||||
|
console.warn('*********************************************************************');
|
||||||
|
console.warn('*');
|
||||||
|
console.warn('*');
|
||||||
|
console.warn('Please change the encryption key from the default value of "CAFEBABE"');
|
||||||
|
console.warn('*');
|
||||||
|
console.warn('*');
|
||||||
|
console.warn('*********************************************************************');
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type { GoogleProfile } from 'next-auth/providers/google';
|
|||||||
import GoogleProvider from 'next-auth/providers/google';
|
import GoogleProvider from 'next-auth/providers/google';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { IdentityProvider } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { ONE_DAY } from '../constants/time';
|
import { ONE_DAY } from '../constants/time';
|
||||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
@ -105,7 +106,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user, trigger, account }) {
|
||||||
const merged = {
|
const merged = {
|
||||||
...token,
|
...token,
|
||||||
...user,
|
...user,
|
||||||
@ -150,6 +151,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') {
|
||||||
|
merged.emailVerified = user?.emailVerified
|
||||||
|
? new Date(user.emailVerified).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: Number(merged.id),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
emailVerified: merged.emailVerified,
|
||||||
|
identityProvider: IdentityProvider.GOOGLE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: merged.id,
|
id: merged.id,
|
||||||
name: merged.name,
|
name: merged.name,
|
||||||
|
|||||||
33
packages/lib/server-only/crypto/decrypt.ts
Normal file
33
packages/lib/server-only/crypto/decrypt.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||||
|
import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt';
|
||||||
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
|
||||||
|
*
|
||||||
|
* @param encryptedData The data encrypted with the `encryptSecondaryData` function.
|
||||||
|
* @returns The decrypted value, or `null` if the data is invalid or expired.
|
||||||
|
*/
|
||||||
|
export const decryptSecondaryData = (encryptedData: string): string | null => {
|
||||||
|
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
|
throw new Error('Missing encryption key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedBufferValue = symmetricDecrypt({
|
||||||
|
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||||
|
data: encryptedData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
|
||||||
|
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.data;
|
||||||
|
};
|
||||||
42
packages/lib/server-only/crypto/encrypt.ts
Normal file
42
packages/lib/server-only/crypto/encrypt.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||||
|
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||||
|
import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema';
|
||||||
|
|
||||||
|
export const ZEncryptedDataSchema = z.object({
|
||||||
|
data: z.string(),
|
||||||
|
expiresAt: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EncryptDataOptions = {
|
||||||
|
data: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the data should no longer be allowed to be decrypted.
|
||||||
|
*
|
||||||
|
* Leave this empty to never expire the data.
|
||||||
|
*/
|
||||||
|
expiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
|
||||||
|
*
|
||||||
|
* @returns The encrypted data.
|
||||||
|
*/
|
||||||
|
export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => {
|
||||||
|
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
|
throw new Error('Missing encryption key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToEncrypt: z.infer<typeof ZEncryptedDataSchema> = {
|
||||||
|
data,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
return symmetricEncrypt({
|
||||||
|
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||||
|
data: JSON.stringify(dataToEncrypt),
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -4,10 +4,11 @@ import { prisma } from '@documenso/prisma';
|
|||||||
|
|
||||||
export type CreateDocumentMetaOptions = {
|
export type CreateDocumentMetaOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
subject: string;
|
subject?: string;
|
||||||
message: string;
|
message?: string;
|
||||||
timezone: string;
|
timezone?: string;
|
||||||
dateFormat: string;
|
password?: string;
|
||||||
|
dateFormat?: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -18,6 +19,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
documentId,
|
documentId,
|
||||||
userId,
|
userId,
|
||||||
|
password,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
await prisma.document.findFirstOrThrow({
|
await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@ -35,12 +37,14 @@ export const upsertDocumentMeta = async ({
|
|||||||
message,
|
message,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
timezone,
|
timezone,
|
||||||
|
password,
|
||||||
documentId,
|
documentId,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
|
password,
|
||||||
timezone,
|
timezone,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
|
|||||||
message: true,
|
message: true,
|
||||||
subject: true,
|
subject: true,
|
||||||
dateFormat: true,
|
dateFormat: true,
|
||||||
|
password: true,
|
||||||
timezone: true,
|
timezone: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { SigningStatus } from '@documenso/prisma/client';
|
|||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
import type { FindResultSet } from '../../types/find-result-set';
|
import type { FindResultSet } from '../../types/find-result-set';
|
||||||
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||||
|
|
||||||
export type FindDocumentsOptions = {
|
export type FindDocumentsOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -173,8 +174,15 @@ export const findDocuments = async ({
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const maskedData = data.map((document) =>
|
||||||
|
maskRecipientTokensForDocument({
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data: maskedData,
|
||||||
count,
|
count,
|
||||||
currentPage: Math.max(page, 1),
|
currentPage: Math.max(page, 1),
|
||||||
perPage,
|
perPage,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
|
|
||||||
export interface GetDocumentAndSenderByTokenOptions {
|
export interface GetDocumentAndSenderByTokenOptions {
|
||||||
token: string;
|
token: string;
|
||||||
@ -58,7 +58,11 @@ export const getDocumentAndRecipientByToken = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: {
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
documentData: true,
|
documentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,6 +42,11 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
|||||||
_all: true,
|
_all: true,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
|
User: {
|
||||||
|
email: {
|
||||||
|
not: user.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: ExtendedDocumentStatus.PENDING,
|
status: ExtendedDocumentStatus.PENDING,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||||
|
|
||||||
export type SearchDocumentsWithKeywordOptions = {
|
export type SearchDocumentsWithKeywordOptions = {
|
||||||
query: string;
|
query: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -77,5 +79,12 @@ export const searchDocumentsWithKeyword = async ({
|
|||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return documents;
|
const maskedDocuments = documents.map((document) =>
|
||||||
|
maskRecipientTokensForDocument({
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return maskedDocuments;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,14 +5,16 @@ import type { Prisma } from '@prisma/client';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
export type UpdateDocumentOptions = {
|
export type UpdateDocumentOptions = {
|
||||||
documentId: number;
|
|
||||||
data: Prisma.DocumentUpdateInput;
|
data: Prisma.DocumentUpdateInput;
|
||||||
|
userId: number;
|
||||||
|
documentId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => {
|
export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => {
|
||||||
return await prisma.document.update({
|
return await prisma.document.update({
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
|
userId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
38
packages/lib/utils/mask-recipient-tokens-for-document.ts
Normal file
38
packages/lib/utils/mask-recipient-tokens-for-document.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithRecipients } from '@documenso/prisma/types/document-with-recipient';
|
||||||
|
|
||||||
|
export type MaskRecipientTokensForDocumentOptions<T extends DocumentWithRecipients> = {
|
||||||
|
document: T;
|
||||||
|
user?: User;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maskRecipientTokensForDocument = <T extends DocumentWithRecipients>({
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
}: MaskRecipientTokensForDocumentOptions<T>) => {
|
||||||
|
const maskedRecipients = document.Recipient.map((recipient) => {
|
||||||
|
if (document.userId === user?.id) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.email === user?.email) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.token === token) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...recipient,
|
||||||
|
token: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
Recipient: maskedRecipients,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta" ADD COLUMN "password" TEXT;
|
||||||
@ -162,6 +162,7 @@ model DocumentMeta {
|
|||||||
subject String?
|
subject String?
|
||||||
message String?
|
message String?
|
||||||
timezone String? @db.Text @default("Etc/UTC")
|
timezone String? @db.Text @default("Etc/UTC")
|
||||||
|
password String?
|
||||||
dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a")
|
dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a")
|
||||||
documentId Int @unique
|
documentId Int @unique
|
||||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export const seedDatabase = async () => {
|
|||||||
create: {
|
create: {
|
||||||
name: 'Example User',
|
name: 'Example User',
|
||||||
email: 'example@documenso.com',
|
email: 'example@documenso.com',
|
||||||
|
emailVerified: new Date(),
|
||||||
password: hashSync('password'),
|
password: hashSync('password'),
|
||||||
roles: [Role.USER],
|
roles: [Role.USER],
|
||||||
},
|
},
|
||||||
@ -31,6 +32,7 @@ export const seedDatabase = async () => {
|
|||||||
create: {
|
create: {
|
||||||
name: 'Admin User',
|
name: 'Admin User',
|
||||||
email: 'admin@documenso.com',
|
email: 'admin@documenso.com',
|
||||||
|
emailVerified: new Date(),
|
||||||
password: hashSync('password'),
|
password: hashSync('password'),
|
||||||
roles: [Role.USER, Role.ADMIN],
|
roles: [Role.USER, Role.ADMIN],
|
||||||
},
|
},
|
||||||
|
|||||||
17
packages/trpc/server/crypto/router.ts
Normal file
17
packages/trpc/server/crypto/router.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||||
|
|
||||||
|
import { procedure, router } from '../trpc';
|
||||||
|
import { ZEncryptSecondaryDataMutationSchema } from './schema';
|
||||||
|
|
||||||
|
export const cryptoRouter = router({
|
||||||
|
encryptSecondaryData: procedure
|
||||||
|
.input(ZEncryptSecondaryDataMutationSchema)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
try {
|
||||||
|
return encryptSecondaryData(input);
|
||||||
|
} catch {
|
||||||
|
// Never leak errors for crypto.
|
||||||
|
throw new Error('Failed to encrypt data');
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
15
packages/trpc/server/crypto/schema.ts
Normal file
15
packages/trpc/server/crypto/schema.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZEncryptSecondaryDataMutationSchema = z.object({
|
||||||
|
data: z.string(),
|
||||||
|
expiresAt: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDecryptDataMutationSchema = z.object({
|
||||||
|
data: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEncryptSecondaryDataMutationSchema = z.infer<
|
||||||
|
typeof ZEncryptSecondaryDataMutationSchema
|
||||||
|
>;
|
||||||
|
export type TDecryptDataMutationSchema = z.infer<typeof ZDecryptDataMutationSchema>;
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||||
@ -13,6 +14,7 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document'
|
|||||||
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -24,6 +26,7 @@ import {
|
|||||||
ZSearchDocumentsMutationSchema,
|
ZSearchDocumentsMutationSchema,
|
||||||
ZSendDocumentMutationSchema,
|
ZSendDocumentMutationSchema,
|
||||||
ZSetFieldsForDocumentMutationSchema,
|
ZSetFieldsForDocumentMutationSchema,
|
||||||
|
ZSetPasswordForDocumentMutationSchema,
|
||||||
ZSetRecipientsForDocumentMutationSchema,
|
ZSetRecipientsForDocumentMutationSchema,
|
||||||
ZSetTitleForDocumentMutationSchema,
|
ZSetTitleForDocumentMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
@ -175,6 +178,38 @@ export const documentRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setPasswordForDocument: authenticatedProcedure
|
||||||
|
.input(ZSetPasswordForDocumentMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { documentId, password } = input;
|
||||||
|
|
||||||
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Missing encryption key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const securePassword = symmetricEncrypt({
|
||||||
|
data: password,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
|
||||||
|
await upsertDocumentMeta({
|
||||||
|
documentId,
|
||||||
|
password: securePassword,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to set the password for this document. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
sendDocument: authenticatedProcedure
|
sendDocument: authenticatedProcedure
|
||||||
.input(ZSendDocumentMutationSchema)
|
.input(ZSendDocumentMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@ -73,6 +73,15 @@ export const ZSendDocumentMutationSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZSetPasswordForDocumentMutationSchema = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSetPasswordForDocumentMutationSchema = z.infer<
|
||||||
|
typeof ZSetPasswordForDocumentMutationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const ZResendDocumentMutationSchema = z.object({
|
export const ZResendDocumentMutationSchema = z.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
recipients: z.array(z.number()).min(1),
|
recipients: z.array(z.number()).min(1),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { adminRouter } from './admin-router/router';
|
import { adminRouter } from './admin-router/router';
|
||||||
import { authRouter } from './auth-router/router';
|
import { authRouter } from './auth-router/router';
|
||||||
|
import { cryptoRouter } from './crypto/router';
|
||||||
import { documentRouter } from './document-router/router';
|
import { documentRouter } from './document-router/router';
|
||||||
import { fieldRouter } from './field-router/router';
|
import { fieldRouter } from './field-router/router';
|
||||||
import { profileRouter } from './profile-router/router';
|
import { profileRouter } from './profile-router/router';
|
||||||
@ -12,6 +13,7 @@ import { twoFactorAuthenticationRouter } from './two-factor-authentication-route
|
|||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
|
crypto: cryptoRouter,
|
||||||
profile: profileRouter,
|
profile: profileRouter,
|
||||||
document: documentRouter,
|
document: documentRouter,
|
||||||
field: fieldRouter,
|
field: fieldRouter,
|
||||||
|
|||||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -8,6 +8,7 @@ declare namespace NodeJS {
|
|||||||
|
|
||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
NEXT_PRIVATE_ENCRYPTION_KEY: string;
|
NEXT_PRIVATE_ENCRYPTION_KEY: string;
|
||||||
|
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useState } from 'react';
|
|||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
import { DocumentData } from '@documenso/prisma/client';
|
import type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog';
|
import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog';
|
||||||
|
|||||||
@ -5,11 +5,11 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { Download } from 'lucide-react';
|
import { Download } from 'lucide-react';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { DocumentData } from '@documenso/prisma/client';
|
import type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { Button } from '../../primitives/button';
|
import { Button } from '../../primitives/button';
|
||||||
import { useToast } from '../../primitives/use-toast';
|
|
||||||
|
|
||||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -24,43 +24,29 @@ export const DocumentDownloadButton = ({
|
|||||||
disabled,
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: DownloadButtonProps) => {
|
}: DownloadButtonProps) => {
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (!documentData) {
|
if (!documentData) {
|
||||||
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await getFile(documentData);
|
await downloadPDF({ documentData, fileName }).then(() => {
|
||||||
|
setIsLoading(false);
|
||||||
const blob = new Blob([bytes], {
|
|
||||||
type: 'application/pdf',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = window.document.createElement('a');
|
|
||||||
const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName;
|
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
|
||||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
|
||||||
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
window.URL.revokeObjectURL(link.href);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
setIsLoading(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Something went wrong',
|
||||||
description: 'An error occurred while downloading your document.',
|
description: 'An error occurred while downloading your document.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,7 @@
|
|||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"tailwind-merge": "^1.12.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"tailwindcss-animate": "^1.0.5",
|
"tailwindcss-animate": "^1.0.5",
|
||||||
"ts-pattern": "^5.0.5"
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.22.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,7 @@ const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps)
|
|||||||
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
||||||
<Command
|
<Command
|
||||||
{...commandProps}
|
{...commandProps}
|
||||||
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
|
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
@ -92,7 +92,7 @@ const CommandGroup = React.forwardRef<
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground mx-2 overflow-hidden border-b pb-2 last:border-0 [&_[cmdk-group-heading]]:mt-2 [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-normal [&_[cmdk-group-heading]]:opacity-50 ',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -121,7 +121,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'aria-selected:bg-accent aria-selected:text-accent-foreground relative -mx-2 -my-1 flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -121,6 +121,7 @@ export const FieldItem = ({
|
|||||||
<button
|
<button
|
||||||
className="text-muted-foreground/50 hover:text-muted-foreground/80 bg-background absolute -right-2 -top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full border"
|
className="text-muted-foreground/50 hover:text-muted-foreground/80 bg-background absolute -right-2 -top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full border"
|
||||||
onClick={() => onRemove?.()}
|
onClick={() => onRemove?.()}
|
||||||
|
onTouchEnd={() => onRemove?.()}
|
||||||
>
|
>
|
||||||
<Trash className="h-4 w-4" />
|
<Trash className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
96
packages/ui/primitives/document-password-dialog.tsx
Normal file
96
packages/ui/primitives/document-password-dialog.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Button } from './button';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormMessage } from './form/form';
|
||||||
|
import { Input } from './input';
|
||||||
|
|
||||||
|
const ZPasswordDialogFormSchema = z.object({
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TPasswordDialogFormSchema = z.infer<typeof ZPasswordDialogFormSchema>;
|
||||||
|
|
||||||
|
type PasswordDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
defaultPassword?: string;
|
||||||
|
onPasswordSubmit?: (password: string) => void;
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PasswordDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
defaultPassword,
|
||||||
|
onPasswordSubmit,
|
||||||
|
isError,
|
||||||
|
}: PasswordDialogProps) => {
|
||||||
|
const form = useForm<TPasswordDialogFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
password: defaultPassword ?? '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZPasswordDialogFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = ({ password }: TPasswordDialogFormSchema) => {
|
||||||
|
onPasswordSubmit?.(password);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError) {
|
||||||
|
form.setError('password', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'The password you have entered is incorrect. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, isError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open}>
|
||||||
|
<DialogContent className="w-full max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Password Required</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
This document is password protected. Please enter the password to view the document.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="bg-background"
|
||||||
|
placeholder="Enter password"
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button>Submit</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,16 +3,19 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
import { type PDFDocumentProxy, PasswordResponses } from 'pdfjs-dist';
|
||||||
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
|
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
|
||||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import type { DocumentData } from '@documenso/prisma/client';
|
import type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { PasswordDialog } from './document-password-dialog';
|
||||||
import { useToast } from './use-toast';
|
import { useToast } from './use-toast';
|
||||||
|
|
||||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
export type LoadedPDFDocument = PDFDocumentProxy;
|
||||||
@ -43,6 +46,9 @@ const PDFLoader = () => (
|
|||||||
export type PDFViewerProps = {
|
export type PDFViewerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
|
document?: DocumentWithData;
|
||||||
|
password?: string | null;
|
||||||
|
onPasswordSubmit?: (password: string) => void | Promise<void>;
|
||||||
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
|
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
|
||||||
onPageClick?: OnPDFViewerPageClick;
|
onPageClick?: OnPDFViewerPageClick;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@ -51,6 +57,8 @@ export type PDFViewerProps = {
|
|||||||
export const PDFViewer = ({
|
export const PDFViewer = ({
|
||||||
className,
|
className,
|
||||||
documentData,
|
documentData,
|
||||||
|
password: defaultPassword,
|
||||||
|
onPasswordSubmit,
|
||||||
onDocumentLoad,
|
onDocumentLoad,
|
||||||
onPageClick,
|
onPageClick,
|
||||||
...props
|
...props
|
||||||
@ -59,7 +67,11 @@ export const PDFViewer = ({
|
|||||||
|
|
||||||
const $el = useRef<HTMLDivElement>(null);
|
const $el = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null);
|
||||||
|
|
||||||
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
|
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
|
||||||
|
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||||
|
const [isPasswordError, setIsPasswordError] = useState(false);
|
||||||
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(null);
|
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(null);
|
||||||
|
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
@ -169,11 +181,27 @@ export const PDFViewer = ({
|
|||||||
<PDFLoader />
|
<PDFLoader />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<PDFDocument
|
<PDFDocument
|
||||||
file={documentBytes.buffer}
|
file={documentBytes.buffer}
|
||||||
className={cn('w-full overflow-hidden rounded', {
|
className={cn('w-full overflow-hidden rounded', {
|
||||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||||
})}
|
})}
|
||||||
|
onPassword={(callback, reason) => {
|
||||||
|
// If the document already has a password, we don't need to ask for it again.
|
||||||
|
if (defaultPassword && reason !== PasswordResponses.INCORRECT_PASSWORD) {
|
||||||
|
callback(defaultPassword);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPasswordModalOpen(true);
|
||||||
|
|
||||||
|
passwordCallbackRef.current = callback;
|
||||||
|
|
||||||
|
match(reason)
|
||||||
|
.with(PasswordResponses.NEED_PASSWORD, () => setIsPasswordError(false))
|
||||||
|
.with(PasswordResponses.INCORRECT_PASSWORD, () => setIsPasswordError(true));
|
||||||
|
}}
|
||||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||||
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
|
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
|
||||||
// Therefore we add some additional custom error handling.
|
// Therefore we add some additional custom error handling.
|
||||||
@ -220,6 +248,20 @@ export const PDFViewer = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</PDFDocument>
|
</PDFDocument>
|
||||||
|
|
||||||
|
<PasswordDialog
|
||||||
|
open={isPasswordModalOpen}
|
||||||
|
onOpenChange={setIsPasswordModalOpen}
|
||||||
|
onPasswordSubmit={(password) => {
|
||||||
|
passwordCallbackRef.current?.(password);
|
||||||
|
|
||||||
|
setIsPasswordModalOpen(false);
|
||||||
|
|
||||||
|
void onPasswordSubmit?.(password);
|
||||||
|
}}
|
||||||
|
isError={isPasswordError}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export const ThemeSwitcher = () => {
|
|||||||
>
|
>
|
||||||
{isMounted && theme === THEMES_TYPE.LIGHT && (
|
{isMounted && theme === THEMES_TYPE.LIGHT && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="bg-background absolute inset-0 rounded-full mix-blend-exclusion"
|
className="bg-background absolute inset-0 rounded-full mix-blend-color-burn"
|
||||||
layoutId="selected-theme"
|
layoutId="selected-theme"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"APP_VERSION",
|
"APP_VERSION",
|
||||||
"NEXT_PRIVATE_ENCRYPTION_KEY",
|
"NEXT_PRIVATE_ENCRYPTION_KEY",
|
||||||
|
"NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY",
|
||||||
"NEXTAUTH_URL",
|
"NEXTAUTH_URL",
|
||||||
"NEXTAUTH_SECRET",
|
"NEXTAUTH_SECRET",
|
||||||
"NEXT_PUBLIC_PROJECT",
|
"NEXT_PUBLIC_PROJECT",
|
||||||
|
|||||||
Reference in New Issue
Block a user