Compare commits

..

54 Commits

Author SHA1 Message Date
7746619dac Merge branch 'main' of https://github.com/documenso/documenso into feat/publicProfile 2024-05-09 12:47:38 +05:30
8f9c07aa8e chore: updated triage label (#1152)
Description:

This PR updates the triage label for new issues
2024-05-08 17:56:23 +05:30
cc4efddabf chore: updated triage label 2024-05-08 17:03:57 +05:30
2ba0f48c61 fix: unauthorized access error api tokens page team (#1134) 2024-05-08 12:03:21 +07:00
5d5d0210fa chore: update github actions (#1085)
**Description:**

This PR updates and adds a new action to assign `status: assigned` label

---------

Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-08 11:52:26 +07:00
e50ccca766 fix: allow template recipients to be filled (#1148)
## Description

Update the template flow to allow for entering recipient placeholder
emails and names

## Changes Made

- General refactoring
- Added advanced recipient settings for future usage
2024-05-07 17:22:24 +07:00
d7a3c40050 feat: add general template enhancements (#1147)
## Description

Refactor the "use template" flow

## Changes Made

- Add placeholders for recipients
- Add audit log when document is created
- Trigger DOCUMENT_CREATED webhook when document is created
- Remove role field when using template
- Remove flaky logic when associating template recipients with form
recipients
- Refactor to use `Form` 

### Using template when document has no recipients

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/a8494ac9-0397-4e3b-a0cf-818c8454a55c">

### Using template with recipients 

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/54d949fc-ed6a-4318-bfd6-6a3179896ba9">

### Using template with the send option selected

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/541b2664-0540-43e9-83dd-e040a45a44ea">
2024-05-07 15:04:12 +07:00
dc11676d28 fix: profile claim name length (#1144)
fixes the caim name length on the profile claim popup
2024-05-07 14:42:16 +07:00
e8d4fe46e5 fix: custom email message for self-signers (#1120) 2024-05-06 09:22:50 +03:00
64e3e2c64b fix: disable encrypted pdfs (#1130)
## Description

Currently if you complete a pending encrypted document, it will prevent
the document from being sealed due to the systems inability to decrypt
it.

This PR disables uploading any documents that cannot be loaded as a
temporary measure.

**Note**
This is a client side only check

## Changes Made

- Disable uploading documents that cannot be parsed
- Refactor putFile to putDocumentFile
- Add a flag as a backup incase something goes wrong
2024-05-03 22:25:24 +07:00
23672d47f1 chore: add copy button
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 17:57:31 +05:30
c60bcc6309 chore: add profile url
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 16:30:34 +05:30
69c175d38e feat: initiated public profile
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 16:16:55 +05:30
15dee5ef35 fix: enforce users to have stripe account (#1131)
## Description

Currently users who sign in via Google SSO do not get assigned a Stripe
customer account.

This enforces the Stripe customer requirement on sign in.

There might be a better place to put this so it's open to any
suggestions.
2024-05-01 16:48:05 +10:00
28d6f6e2e8 fix: improve sealing process (#1133)
## Description

Improves the sealing process by being strict on how long certificate
generation can take, opting to fail generation and continue sealing.

Also changes the ordering of sealing so an error in the process won't
also cause a document to be "COMPLETED" since it hasn't been
cryptographically sealed yet.

The downside to this change is that documents that fail during sealing
will require manual intervention as a signer or owner won't be able to
*complete* the document.

## Testing Performed

- Modified code to force specific failure modes to occur and verified
that documents were either gracefully sealed without a certificate or
not sealed and not completed.
2024-05-01 16:47:11 +10:00
78dc57a6eb fix: improvements from review 2024-05-01 16:16:04 +10:00
d3528f74f0 fix: improve sealing process
Improves the sealing process by being strict on how
long certificate generation can take, opting to fail
generation and continue sealing.

Also changes the ordering of sealing so an error in the
process won't also cause a document to be "COMPLETED"
since it hasn't been cryptographically sealed yet.

The downside to this change is that documents that fail
during sealing will require manual intervention as a signer
or owner won't be able to *complete* the document.
2024-05-01 14:18:01 +10:00
dbd452be97 fix: delete pending documents (#1118)
## Description

Currently deleting a pending document where you are a recipient off will
delete the document, but will also throw an error.

This is due to the recipient being updated after the document deleted,
which is only supposed to happen for completed documents.
2024-04-30 20:53:18 +07:00
5109bb17d6 chore: fix button styling (#1132)
**Description:**

This PR fixes the button styling issue

**Before:**


![image](https://github.com/documenso/documenso/assets/23498248/0af045aa-3714-48d8-9c22-6cd171b07079)

**After:**

<img width="1280" alt="Screenshot 2024-04-30 at 6 48 47 PM"
src="https://github.com/documenso/documenso/assets/23498248/e7dd99de-60fc-4cc2-aefc-21b130aa0116">
2024-04-30 18:57:29 +05:30
6974a76ed4 chore: fix button styling 2024-04-30 18:47:49 +05:30
5efb0894e6 chore: updated dark mode text (#1129)
Description:

This PR updates the dark mode text for this article,
https://app.documenso.com/articles/signature-disclosure
2024-04-30 17:14:28 +05:30
cfec366c1a fix: refactor 2024-04-30 15:54:24 +07:00
8622e68853 fix: add logging 2024-04-30 15:50:22 +07:00
0e16a86e74 chore: updated dark mode text 2024-04-30 11:55:01 +05:30
97d334a1da fix: force users to have a Stripe customer on sign in 2024-04-29 20:15:40 +07:00
345e42537a fix: include all document meta when using the public api 2024-04-29 12:42:22 +10:00
8a24ca2065 fix: complete document when all recipients are CC (#1113)
## Description

Automatically marks the document as completed if all the recipients are
CC.

## Changes Made

Added an if statement in the last form step (`onAddSubjectFormSubmit`)
that checks if all the recipients are CC. If so, the document status is
updated to `COMPLETED`.

## Testing Performed

Tested the changes and they work as expected.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced document sending logic to update document status based on
recipient roles.

- **Bug Fixes**
- Removed redundant form submission handling in the document editing
feature.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-27 21:39:25 +10:00
06dd8219a5 fix: increase trpc max duration (#1121)
## Description

Increase the max duration of the TRPC API endpoint to 120 seconds.
2024-04-27 21:38:21 +10:00
74b9bc786b fix: extend 2024-04-27 18:29:52 +07:00
364c499927 fix: increase trpc max duration 2024-04-27 15:21:46 +07:00
b0ce06f6fe Merge branch 'main' into fix/doc-status-cc-role 2024-04-26 17:17:07 +07:00
20edee7f1a fix: ssr feature flags (#1119)
## Description

Feature flags are broken on SSR due to this error

```
TypeError: fetch failed
    at Object.fetch (node:internal/deps/undici/undici:11731:11)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
  cause: RequestContentLengthMismatchError: Request body length does not match content-length header
      at write (node:internal/deps/undici/undici:8590:41)
      at _resume (node:internal/deps/undici/undici:8563:33)
      at resume (node:internal/deps/undici/undici:8459:7)
      at [dispatch] (node:internal/deps/undici/undici:7704:11)
      at Client.Intercept (node:internal/deps/undici/undici:7377:20)
      at Client.dispatch (node:internal/deps/undici/undici:6023:44)
      at [dispatch] (node:internal/deps/undici/undici:6254:32)
      at Pool.dispatch (node:internal/deps/undici/undici:6023:44)
      at [dispatch] (node:internal/deps/undici/undici:9343:27)
      at Agent.Intercept (node:internal/deps/undici/undici:7377:20) {
    code: 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'
  }
}
```

I've removed content-length header since it isn't mandatory to my
knowledge for get requests.

## Changes

- Add fallback local flags when individual flag request fails
- Add error logging
- Remove `content-length` from headers being passed to Posthog
2024-04-26 16:01:09 +07:00
9bc5818d19 fix: use cdp and upgrade playwright again (#1117)
## Description

Upgrade playwright once again and use CDP for remote connections to
avoid version lock-in with `playwright.connect`

Resolves the issue where all CI is failing currently due to downgrading
playwright.
2024-04-26 15:56:55 +10:00
481d739c37 chore: update package-lock 2024-04-26 13:25:16 +10:00
88dedc9829 fix: use cdp and upgrade playwright again 2024-04-26 13:18:31 +10:00
4080806606 fix: minor updates 2024-04-26 02:17:56 +00:00
e949fb14ae fix: hide team webhooks from users (#1116)
## Description

Currently if you create a team webhook, you can see it in your personal
webhooks.

However, interacting with them will throw errors because there is server
side logic preventing any interaction with them since they are outside
of the correct context.

So the solution is to either:
- Allow the user to edit the team webhooks that they created on their
personal webhooks page
- Hide team webhooks for personal webhooks pages

This PR goes with the second option, but is open to suggestions.
2024-04-26 11:14:52 +10:00
e1573465f6 fix: hide team webhooks from users 2024-04-25 23:32:59 +07:00
0062359977 feat: add visible completed fields (#1109)
## Description

Added the ability for recipients to see fields from other recipients who
have completed the document when they are signing the document

Added the ability for the document owner to see fields from recipients
who have completed the field on the document page view (only visible
when the document is pending)


## 🚨🚨 Migrations🚨🚨

- Drop all `Fields` that do not have a `Recipient` set (not sure how it
was possible in the first place)
- Remove optional `Recipient` field on `Field` which doesn't make sense 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Enhanced document viewing by adding read-only fields based on document
status.
- Improved signing page by fetching and displaying completed fields for
tokens.
- Updated avatar component to show recipient status with tooltips for
better user interaction.

- **Bug Fixes**
- Made `recipientId` a required field in the database to ensure data
consistency.

- **Refactor**
- Optimized popover functionality in UI components for better
performance and user experience.

- **Documentation**
- Added detailed component and function descriptions for new features in
the system.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-25 20:53:13 +10:00
03727bfad2 fix: disable cert download (#1114)
## Description

Disable the document certificate download button when the document is
not complete

## Changes Made

- Disable UI button
- Disable TRPC API endpoint

## Testing Performed

Tested locally for pending, draft and completed documents
2024-04-25 20:43:52 +10:00
c4a680caf7 fix: hide account action reauth (#1115)
## Description

Hide the account reauth option

<img width="527" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/ee2169c7-856b-41ae-b756-43f15c2e8f69">

<img width="527" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/42ecc50a-2a7d-461b-9994-1af8f4a147ed">
2024-04-25 20:42:39 +10:00
1e33bc2aa3 Merge branch 'main' into fix/doc-status-cc-role 2024-04-24 20:30:10 +07:00
4de122f814 fix: hide account action reauth 2024-04-24 20:07:38 +07:00
e4cf9c8251 fix: add server logic 2024-04-24 19:51:18 +07:00
41ed6c9ad7 fix: disable cert download when document not complete 2024-04-24 19:49:10 +07:00
d7959950e2 fix: edit-document line 2024-04-24 09:41:34 +03:00
bb43547a45 fix: complete document when all recipients are CC 2024-04-24 09:39:47 +03:00
3fb69422e8 Merge branch 'main' into fix/doc-status-cc-role 2024-04-23 14:26:37 +03:00
4d5365bddc fix: complete document when all recipients are CC 2024-04-23 14:24:58 +03:00
0eee570781 fix: complete document when all recipients are CC 2024-04-23 12:33:40 +03:00
afaeba9739 fix: resize fields 2024-04-22 13:31:49 +07:00
f6e6dac46c fix: update migration to drop invalid fields 2024-04-19 17:58:32 +07:00
a97ffa97a4 Merge branch 'main' into feat/visible-fields 2024-04-19 17:54:32 +07:00
6526377f1b feat: add visible completed fields 2024-04-18 21:56:31 +07:00
76 changed files with 1900 additions and 878 deletions

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@ -33,9 +33,9 @@ jobs:
- uses: ./.github/actions/cache-build - uses: ./.github/actions/cache-build
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v3

View File

@ -33,7 +33,7 @@ jobs:
- name: Run Playwright tests - name: Run Playwright tests
run: npm run ci run: npm run ci
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: test-results name: test-results

View File

@ -27,7 +27,7 @@ jobs:
- name: Check Assigned User's Issue Count - name: Check Assigned User's Issue Count
id: parse-comment id: parse-comment
uses: actions/github-script@v5 uses: actions/github-script@v6
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

25
.github/workflows/issue-labeler.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Auto Label Assigned Issues
on:
issues:
types: [assigned]
jobs:
label-when-assigned:
runs-on: ubuntu-latest
steps:
- name: Label issue
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const issue = context.issue;
// To run only on issues and not on PR
if (github.context.payload.issue.pull_request === undefined) {
const labelResponse = await github.rest.issues.addLabels({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
labels: ['status: assigned']
});
}

View File

@ -17,5 +17,5 @@ jobs:
issue_number: context.issue.number, issue_number: context.issue.number,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
labels: ["needs triage"] labels: ["status: triage"]
}) })

View File

@ -2,14 +2,14 @@ name: 'PR Review Reminder'
on: on:
pull_request: pull_request:
types: ['opened', 'reopened', 'ready_for_review', 'review_requested'] types: ['opened', 'ready_for_review']
permissions: permissions:
pull-requests: write pull-requests: write
jobs: jobs:
checkPRs: checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested') if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

View File

@ -12,7 +12,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@v4 - uses: actions/stale@v5
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 90 days-before-pr-stale: 90

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client'; import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
} }
try { try {
const putFileData = await putFile(uploadedFile.file); const putFileData = await putPdfFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({ const documentToken = await createSinglePlayerDocument({
documentData: { documentData: {

View File

@ -8,6 +8,7 @@ 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 { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-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 { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@ -20,6 +21,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { import {
DocumentStatus as DocumentStatusComponent, DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP, FRIENDLY_STATUS_MAP,
@ -84,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword; documentMeta.password = securePassword;
} }
const recipients = await getRecipientsForDocument({ const [recipients, completedFields] = await Promise.all([
documentId, getRecipientsForDocument({
teamId: team?.id, documentId,
userId: user.id, teamId: team?.id,
}); userId: user.id,
}),
getCompletedFieldsForDocument({
documentId,
}),
]);
const documentWithRecipients = { const documentWithRecipients = {
...document, ...document,
@ -155,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
</CardContent> </CardContent>
</Card> </Card>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields
fields={completedFields}
documentMeta={document.documentMeta || undefined}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6"> <div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6"> <section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">

View File

@ -224,10 +224,6 @@ export const EditDocumentForm = ({
} }
}; };
const setSubjectFormFields = (subject?: string, message?: string) => {
// Add functionality here
};
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try { try {
await addFields({ await addFields({
@ -363,7 +359,6 @@ export const EditDocumentForm = ({
fields={fields} fields={fields}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
setSubjectFormFields={setSubjectFormFields}
/> />
</Stepper> </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>

View File

@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
</div> </div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end"> <div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DownloadCertificateButton className="mr-2" documentId={document.id} /> <DownloadCertificateButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DownloadAuditLogButton documentId={document.id} /> <DownloadAuditLogButton documentId={document.id} />
</div> </div>

View File

@ -2,6 +2,7 @@
import { DownloadIcon } from 'lucide-react'; import { DownloadIcon } from 'lucide-react';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadCertificateButtonProps = { export type DownloadCertificateButtonProps = {
className?: string; className?: string;
documentId: number; documentId: number;
documentStatus: DocumentStatus;
}; };
export const DownloadCertificateButton = ({ export const DownloadCertificateButton = ({
className, className,
documentId, documentId,
documentStatus,
}: DownloadCertificateButtonProps) => { }: DownloadCertificateButtonProps) => {
const { toast } = useToast(); const { toast } = useToast();
@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({
className={cn('w-full sm:w-auto', className)} className={cn('w-full sm:w-auto', className)}
loading={isLoading} loading={isLoading}
variant="outline" variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED}
onClick={() => void onDownloadCertificatesClick()} onClick={() => void onDownloadCertificatesClick()}
> >
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />} {!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}

View File

@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
try { try {
setIsLoading(true); setIsLoading(true);
const { type, data } = await putFile(file); const { type, data } = await putPdfFile(file);
const { id: documentDataId } = await createDocumentData({ const { id: documentDataId } = await createDocumentData({
type, type,
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
}); });
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`); router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) { } catch (err) {
console.error(error); const error = AppError.parseError(err);
if (error instanceof TRPCClientError) { console.error(err);
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({ toast({
title: 'Error', title: 'Error',
description: error.message, description: err.message,
variant: 'destructive', variant: 'destructive',
}); });
} else { } else {

View File

@ -0,0 +1,9 @@
import React from 'react';
export type PublicProfileSettingsLayout = {
children: React.ReactNode;
};
export default function PublicProfileSettingsLayout({ children }: PublicProfileSettingsLayout) {
return <div className="col-span-12 md:col-span-9">{children}</div>;
}

View File

@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Switch } from '@documenso/ui/primitives/switch';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { LinkTemplatesForm } from '~/components/forms/link-templates';
import { PublicProfileForm } from '~/components/forms/public-profile';
export const metadata: Metadata = {
title: 'Public profile',
};
export default async function PublicProfileSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<SettingsHeader
title="Public profile"
subtitle="You can choose to enable/disable your profile for public view"
className="justify mb-8"
hideDivider
>
<div className="flex flex-row">
<label className="mr-2 text-white" htmlFor="hide">
Hide
</label>
<Switch />
<label className="ml-2 text-white" htmlFor="show">
Show
</label>
</div>
</SettingsHeader>
<PublicProfileForm className="mb-8 max-w-xl" user={user} />
<LinkTemplatesForm />
</div>
);
}

View File

@ -141,6 +141,8 @@ export const EditTemplateForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
// Todo: Add when we setup template settings.
isTemplateOwnerEnterprise={false}
/> />
<AddTemplateFieldsFormPartial <AddTemplateFieldsFormPartial

View File

@ -58,7 +58,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
]); ]);
return ( return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80"> <Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" /> <ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates Templates
@ -73,7 +73,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
</div> </div>
<EditTemplateForm <EditTemplateForm
className="mt-8" className="mt-6"
template={template} template={template}
user={user} user={user}
recipients={templateRecipients} recipients={templateRecipients}

View File

@ -12,7 +12,7 @@ import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
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 { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -98,7 +98,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const file: File = uploadedFile.file; const file: File = uploadedFile.file;
try { try {
const { type, data } = await putFile(file); const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({ const { id: templateDocumentDataId } = await createDocumentData({
type, type,

View File

@ -1,14 +1,21 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react'; import { InfoIcon, Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
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 { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@ -19,24 +26,59 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons'; import type { Toast } from '@documenso/ui/primitives/use-toast';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({ const ZAddRecipientsForNewDocumentSchema = z
recipients: z.array( .object({
z.object({ sendDocument: z.boolean(),
email: z.string().email(), recipients: z.array(
name: z.string(), z.object({
role: z.nativeEnum(RecipientRole), id: z.number(),
}), email: z.string().email(),
), name: z.string(),
}); }),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>; type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -54,35 +96,33 @@ export function UseTemplateDialog({
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false);
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { const form = useForm<TAddRecipientsForNewDocumentSchema>({
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: { defaultValues: {
recipients: sendDocument: false,
recipients.length > 0 recipients: recipients.map((recipient) => {
? recipients.map((recipient) => ({ const isRecipientEmailPlaceholder = recipient.email.match(
nativeId: recipient.id, TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
formId: String(recipient.id), );
name: recipient.name,
email: recipient.email, const isRecipientNamePlaceholder = recipient.name.match(
role: recipient.role, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
})) );
: [
{ return {
name: '', id: recipient.id,
email: '', name: !isRecipientNamePlaceholder ? recipient.name : '',
role: RecipientRole.SIGNER, email: !isRecipientEmailPlaceholder ? recipient.email : '',
}, };
], }),
}, },
}); });
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } = const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation(); trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
@ -91,6 +131,7 @@ export function UseTemplateDialog({
templateId, templateId,
teamId: team?.id, teamId: team?.id,
recipients: data.recipients, recipients: data.recipients,
sendDocument: data.sendDocument,
}); });
toast({ toast({
@ -101,23 +142,35 @@ export function UseTemplateDialog({
router.push(`${documentRootPath}/${id}`); router.push(`${documentRootPath}/${id}`);
} catch (err) { } catch (err) {
toast({ const error = AppError.parseError(err);
const toastPayload: Toast = {
title: 'Error', title: 'Error',
description: 'An error occurred while creating document from template.', description: 'An error occurred while creating document from template.',
variant: 'destructive', variant: 'destructive',
}); };
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = 'The document was created but could not be sent to recipients.';
}
toast(toastPayload);
} }
}; };
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({ const { fields: formRecipients } = useFieldArray({
control, control: form.control,
name: 'recipients', name: 'recipients',
}); });
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return ( return (
<Dialog> <Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer"> <Button className="cursor-pointer">
<Plus className="-ml-1 mr-2 h-4 w-4" /> <Plus className="-ml-1 mr-2 h-4 w-4" />
@ -126,121 +179,110 @@ export function UseTemplateDialog({
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Document Recipients</DialogTitle> <DialogTitle>Create document from template</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription> <DialogDescription>
{recipients.length === 0
? 'A draft document will be created'
: 'Add the recipients to create the document with'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller <Form {...form}>
control={control} <form onSubmit={form.handleSubmit(onSubmit)}>
name={`recipients.${index}.email`} <fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
render={({ field }) => ( <div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
<Input {formRecipients.map((recipient, index) => (
id={`recipient-${recipient.id}-email`} <div className="flex w-full flex-row space-x-4" key={recipient.id}>
type="email" <FormField
className="bg-background mt-2" control={form.control}
disabled={isSubmitting} name={`recipients.${index}.email`}
{...field} render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].email || 'Email'} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
)}
/>
</div>
<div className="flex-1"> <FormField
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label> control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel>Name</FormLabel>}
<Controller <FormControl>
control={control} <Input {...field} placeholder={recipients[index].name || 'Name'} />
name={`recipients.${index}.name`} </FormControl>
render={({ field }) => ( <FormMessage />
<Input </FormItem>
id={`recipient-${recipient.id}-name`} )}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
/> />
)} </div>
/> ))}
</div> </div>
<div className="w-[60px]"> {recipients.length > 0 && (
<Controller <div className="mt-4 flex flex-row items-center">
control={control} <FormField
name={`recipients.${index}.role`} control={form.control}
render={({ field: { value, onChange } }) => ( name="sendDocument"
<Select value={value} onValueChange={(x) => onChange(x)}> render={({ field }) => (
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger> <FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<SelectContent className="" align="end"> <label
<SelectItem value={RecipientRole.SIGNER}> className="text-muted-foreground ml-2 flex items-center text-sm"
<div className="flex items-center"> htmlFor="sendDocument"
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span> >
Signer Send document
</div> <Tooltip>
</SelectItem> <TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<SelectItem value={RecipientRole.CC}> <TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<div className="flex items-center"> <p>
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span> The document will be immediately sent to recipients if this is
Receives copy checked.
</div> </p>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}> <p>Otherwise, the document will be created as a draft.</p>
<div className="flex items-center"> </TooltipContent>
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span> </Tooltip>
Approver </label>
</div> </div>
</SelectItem> </FormItem>
)}
/>
</div>
)}
<SelectItem value={RecipientRole.VIEWER}> <DialogFooter>
<div className="flex items-center"> <DialogClose asChild>
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span> <Button type="button" variant="secondary">
Viewer Close
</div> </Button>
</SelectItem> </DialogClose>
</SelectContent>
</Select>
)}
/>
</div>
<div className="w-full"> <Button type="submit" loading={form.formState.isSubmitting}>
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} /> {form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} /> </Button>
</div> </DialogFooter>
</div> </fieldset>
))} </form>
</div> </Form>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -6,6 +6,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { 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';
@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([ const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
userId: user?.id, userId: user?.id,
@ -45,6 +46,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
}).catch(() => null), }).catch(() => null),
getFieldsForToken({ token }), getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }),
]); ]);
if ( if (
@ -125,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
signature={user?.email === recipient.email ? user.signature : undefined} signature={user?.email === recipient.email ? user.signature : undefined}
> >
<DocumentAuthProvider document={document} recipient={recipient} user={user}> <DocumentAuthProvider document={document} recipient={recipient} user={user}>
<SigningPageView recipient={recipient} document={document} fields={fields} /> <SigningPageView
recipient={recipient}
document={document}
fields={fields}
completedFields={completedFields}
/>
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>
); );

View File

@ -4,12 +4,14 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
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';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client'; import { FieldType, RecipientRole } 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';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field'; import { DateField } from './date-field';
@ -23,9 +25,15 @@ export type SigningPageViewProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: Recipient;
fields: Field[]; fields: Field[];
completedFields: CompletedField[];
}; };
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => { export const SigningPageView = ({
document,
recipient,
fields,
completedFields,
}: SigningPageViewProps) => {
const truncatedTitle = truncateTitle(document.title); const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
</div> </div>
</div> </div>
<DocumentReadOnlyFields fields={completedFields} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}> <ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) => {fields.map((field) =>
match(field.type) match(field.type)

View File

@ -1,7 +1,10 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { GetTeamTokensResponse } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens'; import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -23,7 +26,24 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const team = await getTeamByUrl({ userId: user.id, teamUrl }); const team = await getTeamByUrl({ userId: user.id, teamUrl });
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }); let tokens: GetTeamTokensResponse | null = null;
try {
tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
} catch (err) {
const error = AppError.parseError(err);
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
{match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => error.message)
.otherwise(() => 'Something went wrong.')}
</p>
</div>
);
}
return ( return (
<div> <div>

View File

@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() { export default function SignatureDisclosure() {
return ( return (
<div> <div>
<article className="prose"> <article className="prose dark:prose-invert">
<h1>Electronic Signature Disclosure</h1> <h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2> <h2>Welcome</h2>

View File

@ -1,12 +1,10 @@
'use client'; 'use client';
import { useRef, useState } from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client'; import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { PopoverHover } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient'; import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
@ -25,11 +23,6 @@ export const StackAvatarsWithTooltip = ({
position, position,
children, children,
}: StackAvatarsWithTooltipProps) => { }: StackAvatarsWithTooltipProps) => {
const [open, setOpen] = useState(false);
const isControlled = useRef(false);
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
const waitingRecipients = recipients.filter( const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'waiting', (recipient) => getRecipientType(recipient) === 'waiting',
); );
@ -46,117 +39,74 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === 'unsigned', (recipient) => getRecipientType(recipient) === 'unsigned',
); );
const onMouseEnter = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
isMouseOverTimeout.current = setTimeout(() => {
setOpen((o) => (!o ? true : o));
}, 200);
};
const onMouseLeave = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
setTimeout(() => {
setOpen((o) => (o ? false : o));
}, 200);
};
const onOpenChange = (newOpen: boolean) => {
isControlled.current = newOpen;
setOpen(newOpen);
};
return ( return (
<Popover open={open} onOpenChange={onOpenChange}> <PopoverHover
<PopoverTrigger trigger={children || <StackAvatars recipients={recipients} />}
className="flex cursor-pointer" contentProps={{
onMouseEnter={onMouseEnter} className: 'flex flex-col gap-y-5 py-2',
onMouseLeave={onMouseLeave} side: position,
> }}
{children || <StackAvatars recipients={recipients} />} >
</PopoverTrigger> {completedRecipients.length > 0 && (
<div>
<PopoverContent <h1 className="text-base font-medium">Completed</h1>
side={position} {completedRecipients.map((recipient: Recipient) => (
onMouseEnter={onMouseEnter} <div key={recipient.id} className="my-1 flex items-center gap-2">
onMouseLeave={onMouseLeave} <StackAvatar
className="flex flex-col gap-y-5 py-2" first={true}
> key={recipient.id}
{completedRecipients.length > 0 && ( type={getRecipientType(recipient)}
<div> fallbackText={recipientAbbreviation(recipient)}
<h1 className="text-base font-medium">Completed</h1> />
{completedRecipients.map((recipient: Recipient) => ( <div className="">
<div key={recipient.id} className="my-1 flex items-center gap-2"> <p className="text-muted-foreground text-sm">{recipient.email}</p>
<StackAvatar <p className="text-muted-foreground/70 text-xs">
first={true} {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
key={recipient.id} </p>
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div> </div>
))} </div>
</div> ))}
)} </div>
)}
{waitingRecipients.length > 0 && ( {waitingRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Waiting</h1> <h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => ( {waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}
documentStatus={documentStatus} documentStatus={documentStatus}
/> />
))} ))}
</div> </div>
)} )}
{openedRecipients.length > 0 && ( {openedRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Opened</h1> <h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => ( {openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}
documentStatus={documentStatus} documentStatus={documentStatus}
/> />
))} ))}
</div> </div>
)} )}
{uncompletedRecipients.length > 0 && ( {uncompletedRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Uncompleted</h1> <h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => ( {uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}
documentStatus={documentStatus} documentStatus={documentStatus}
/> />
))} ))}
</div> </div>
)} )}
</PopoverContent> </PopoverHover>
</Popover>
); );
}; };

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react'; import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -101,6 +101,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button> </Button>
</Link> </Link>
)} )}
<Link href="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2 className="mr-2 h-5 w-5" />
Public profile
</Button>
</Link>
</div> </div>
); );
}; };

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react'; import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -104,6 +104,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button> </Button>
</Link> </Link>
)} )}
<Link href="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2 className="mr-2 h-5 w-5" />
Public profile
</Button>
</Link>
</div> </div>
); );
}; };

View File

@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { CompletedField } from '@documenso/lib/types/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentMeta } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: CompletedField[];
documentMeta?: DocumentMeta;
};
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.Recipient.name || field.Recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'flex w-fit flex-col py-2.5 text-sm',
}}
>
<p>
<span className="font-semibold">
{field.Recipient.name
? `${field.Recipient.name} (${field.Recipient.email})`
: field.Recipient.email}{' '}
</span>
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
</p>
<Button
variant="outline"
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
>
Hide field
</Button>
</PopoverHover>
</div>
<div className="text-muted-foreground break-all text-sm">
{match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.Signature?.signatureImageAsBase64 ? (
<img
src={field.Signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{field.Signature?.typedSignature}
</p>
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
() => field.customText,
)
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -0,0 +1,26 @@
'use client';
import { File } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export const LinkTemplatesForm = () => {
return (
<div className={cn('flex max-w-xl flex-row items-center justify-between')}>
<div>
<h3 className="text-lg font-medium">My templates</h3>
<p className="text-muted-foreground text-sm md:mt-2">
Create templates to display in your public profile
</p>
</div>
<div>
<Button type="submit" variant="outline" className="self-end p-4">
<File className="mr-2" /> Link template
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,182 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Copy } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = z.object({
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
bio: z
.string()
.trim()
.max(256, {
message: 'Bio cannot be longer than 256 characters.',
})
.optional(),
});
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileFormProps = {
className?: string;
user: User;
};
export const PublicProfileForm = ({ className, user }: PublicProfileFormProps) => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<TPublicProfileFormSchema>({
values: {
url: user.url || '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
const watchedBio = form.watch('bio');
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ url, bio }: TPublicProfileFormSchema) => {
try {
await updatePublicProfile({
url,
bio,
});
toast({
title: 'Public profile updated',
description: 'Your public profile has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to sign you In. Please try again later.',
});
}
}
};
const handleCopyUrl = () => {
const profileUrl = `documenso.com/u/${user.url}`;
navigator.clipboard
.writeText(profileUrl)
.then(() => {
toast({
title: 'URL Copied',
description: 'The profile URL has been copied to your clipboard.',
duration: 3000,
});
})
.catch((err) => {
console.error('Failed to copy: ', err);
toast({
title: 'Error',
description: 'Failed to copy the URL to clipboard.',
variant: 'destructive',
duration: 3000,
});
});
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-8" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile URL</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="bg-muted flex w-full items-center rounded pl-2">
<span className="text-xs">documenso.com/u/{user.url}</span>
<Button
variant="ghost"
onClick={handleCopyUrl}
className="flex items-center justify-center rounded p-2 hover:bg-gray-200"
aria-label="Copy URL"
>
<Copy className="h-5 w-5" />
</Button>
</div>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<>
<Textarea placeholder="Write about yourself..." {...field} />
<div className="text-muted-foreground text-left text-sm">
{256 - (watchedBio?.length || 0)} characters remaining
</div>
</>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isSubmitting} className="self-end">
{isSubmitting ? 'Saving changes...' : 'Save changes'}
</Button>
</form>
</Form>
);
};

View File

@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth'; import NextAuth from 'next-auth';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
error: '/signin', error: '/signin',
}, },
events: { events: {
signIn: async ({ user }) => { signIn: async ({ user: { id: userId } }) => {
await prisma.userSecurityAuditLog.create({ const [user] = await Promise.all([
data: { await prisma.user.findFirstOrThrow({
userId: user.id, where: {
ipAddress, id: userId,
userAgent, },
type: UserSecurityAuditLogType.SIGN_IN, }),
}, await prisma.userSecurityAuditLog.create({
}); data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
}),
]);
// Create the Stripe customer and attach it to the user if it doesn't exist.
if (user.customerId === null && IS_BILLING_ENABLED()) {
await getStripeCustomerByUser(user).catch((err) => {
console.error(err);
});
}
}, },
signOut: async ({ token }) => { signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id; const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;

View File

@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
export const config = { export const config = {
maxDuration: 60, maxDuration: 120,
api: { api: {
bodyParser: { bodyParser: {
sizeLimit: '50mb', sizeLimit: '50mb',

96
package-lock.json generated
View File

@ -22,7 +22,7 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.41.0", "playwright": "1.43.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"
@ -4702,13 +4702,26 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/browser-chromium": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
"integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.43.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.40.0", "version": "1.43.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz",
"integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==", "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright": "1.40.0" "playwright": "1.43.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -4732,12 +4745,12 @@
} }
}, },
"node_modules/@playwright/test/node_modules/playwright": { "node_modules/@playwright/test/node_modules/playwright": {
"version": "1.40.0", "version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright-core": "1.40.0" "playwright-core": "1.43.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -4750,9 +4763,9 @@
} }
}, },
"node_modules/@playwright/test/node_modules/playwright-core": { "node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.40.0", "version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
"dev": true, "dev": true,
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@ -17660,11 +17673,11 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.41.0", "version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
"integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
"dependencies": { "dependencies": {
"playwright-core": "1.41.0" "playwright-core": "1.43.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -17676,6 +17689,17 @@
"fsevents": "2.3.2" "fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/playwright/node_modules/fsevents": { "node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@ -17689,17 +17713,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/playwright/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -24968,7 +24981,7 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.41.0", "playwright": "1.43.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -24976,23 +24989,10 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "1.41.0", "@playwright/browser-chromium": "1.43.0",
"@types/luxon": "^3.3.1" "@types/luxon": "^3.3.1"
} }
}, },
"packages/lib/node_modules/@playwright/browser-chromium": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz",
"integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.41.0"
},
"engines": {
"node": ">=16"
}
},
"packages/lib/node_modules/nanoid": { "packages/lib/node_modules/nanoid": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
@ -25010,18 +25010,6 @@
"node": "^14 || ^16 || >=18" "node": "^14 || ^16 || >=18"
} }
}, },
"packages/lib/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"packages/prettier-config": { "packages/prettier-config": {
"name": "@documenso/prettier-config", "name": "@documenso/prettier-config",
"version": "0.0.0", "version": "0.0.0",

View File

@ -38,7 +38,7 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.41.0", "playwright": "1.43.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"

View File

@ -19,10 +19,10 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
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 { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { import {
getPresignGetUrl, getPresignGetUrl,
getPresignPostUrl, getPresignPostUrl,
@ -229,6 +229,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
requestMetadata: extractNextApiRequestMetadata(args.req), requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({ const recipients = await setRecipientsForDocument({
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
@ -279,7 +286,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`; const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplate({ const document = await createDocumentFromTemplateLegacy({
templateId, templateId,
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
@ -296,7 +303,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
formValues: body.formValues, formValues: body.formValues,
}); });
const newDocumentData = await putFile({ const newDocumentData = await putPdfFile({
name: fileName, name: fileName,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled), arrayBuffer: async () => Promise.resolve(prefilled),
@ -324,10 +331,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({ await upsertDocumentMeta({
documentId: document.id, documentId: document.id,
userId: user.id, userId: user.id,
subject: body.meta.subject, ...body.meta,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
requestMetadata: extractNextApiRequestMetadata(args.req), requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
} }

View File

@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template. // Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click(); await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/); await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents'); await page.waitForURL('/documents');
@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use team template. // Use team template.
await page.getByRole('button', { name: 'Use Template' }).click(); await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/); await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`); await page.waitForURL(`/t/${team.url}/documents`);

View File

@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { alphaid, nanoid } from '@documenso/lib/universal/id'; import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentStatus, DocumentStatus,
@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url), new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
).then(async (res) => res.arrayBuffer()); ).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putFile({ const { id: documentDataId } = await putPdfFile({
name: 'Documenso Supporter Pledge.pdf', name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer), arrayBuffer: async () => Promise.resolve(documentBuffer),

View File

@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account. * Does not take any person or group properties into account.
*/ */
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = { export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true', app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false, app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag. app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.

View File

@ -0,0 +1,2 @@
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;

View File

@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
@ -149,4 +150,24 @@ export class AppError extends Error {
return null; return null;
} }
} }
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.otherwise(() => 500 as const);
return {
status,
body: {
message: status !== 500 ? error.message : 'Something went wrong',
},
};
}
} }

View File

@ -39,7 +39,7 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.41.0", "playwright": "1.43.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -48,6 +48,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@playwright/browser-chromium": "1.41.0" "@playwright/browser-chromium": "1.43.0"
} }
} }

View File

@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
await sendPendingEmail({ documentId, recipientId: recipient.id }); await sendPendingEmail({ documentId, recipientId: recipient.id });
} }
const documents = await prisma.document.updateMany({ const haveAllRecipientsSigned = await prisma.document.findFirst({
where: { where: {
id: document.id, id: document.id,
Recipient: { Recipient: {
@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
}, },
}, },
}, },
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
}); });
if (documents.count > 0) { if (haveAllRecipientsSigned) {
await sealDocument({ documentId: document.id, requestMetadata }); await sealDocument({ documentId: document.id, requestMetadata });
} }

View File

@ -75,18 +75,20 @@ export const deleteDocument = async ({
} }
// Continue to hide the document from the user if they are a recipient. // Continue to hide the document from the user if they are a recipient.
// Dirty way of doing this but it's faster than refetching the document.
if (userRecipient?.documentDeletedAt === null) { if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient.update({ await prisma.recipient
where: { .update({
documentId_email: { where: {
documentId: document.id, id: userRecipient.id,
email: user.email,
}, },
}, data: {
data: { documentDeletedAt: new Date().toISOString(),
documentDeletedAt: new Date().toISOString(), },
}, })
}); .catch(() => {
// Do nothing.
});
} }
// Return partial document for API v1 response. // Return partial document for API v1 response.

View File

@ -110,7 +110,7 @@ export const resendDocument = async ({
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate( customBody: renderCustomEmailTemplate(
selfSigner ? selfSignerCustomEmail : customEmail?.message || '', selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate, customEmailTemplate,
), ),
role: recipient.role, role: recipient.role,
@ -135,7 +135,7 @@ export const resendDocument = async ({
address: FROM_ADDRESS, address: FROM_ADDRESS,
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
: emailSubject, : emailSubject,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),

View File

@ -14,7 +14,7 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file'; import { putPdfFile } from '../../universal/upload/put-file';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenAnnotations } from '../pdf/flatten-annotations';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@ -40,6 +40,11 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
}, },
include: { include: {
documentData: true, documentData: true,
@ -53,10 +58,6 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has no document data`); throw new Error(`Document ${document.id} has no document data`);
} }
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
where: { where: {
documentId: document.id, documentId: document.id,
@ -92,9 +93,9 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy // !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData); const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId }).then(async (doc) => const certificate = await getCertificatePdf({ documentId })
PDFDocument.load(doc), .then(async (doc) => PDFDocument.load(doc))
); .catch(() => null);
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
@ -103,11 +104,13 @@ export const sealDocument = async ({
doc.getForm().flatten(); doc.getForm().flatten();
flattenAnnotations(doc); flattenAnnotations(doc);
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices()); if (certificate) {
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => { certificatePages.forEach((page) => {
doc.addPage(page); doc.addPage(page);
}); });
}
for (const field of fields) { for (const field of fields) {
await insertFieldInPDF(doc, field); await insertFieldInPDF(doc, field);
@ -119,7 +122,7 @@ export const sealDocument = async ({
const { name, ext } = path.parse(document.title); const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({ const { data: newData } = await putPdfFile({
name: `${name}_signed${ext}`, name: `${name}_signed${ext}`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBuffer),
@ -138,6 +141,16 @@ export const sealDocument = async ({
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({ await tx.documentData.update({
where: { where: {
id: documentData.id, id: documentData.id,

View File

@ -4,8 +4,11 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -18,7 +21,6 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles'; } from '../../constants/recipient-roles';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -100,7 +102,7 @@ export const sendDocument = async ({
formValues: document.formValues as Record<string, string | number | boolean>, formValues: document.formValues as Record<string, string | number | boolean>,
}); });
const newDocumentData = await putFile({ const newDocumentData = await putPdfFile({
name: document.title, name: document.title,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled), arrayBuffer: async () => Promise.resolve(prefilled),
@ -149,7 +151,7 @@ export const sendDocument = async ({
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate( customBody: renderCustomEmailTemplate(
selfSigner ? selfSignerCustomEmail : customEmail?.message || '', selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate, customEmailTemplate,
), ),
role: recipient.role, role: recipient.role,
@ -211,6 +213,31 @@ export const sendDocument = async ({
}), }),
); );
const allRecipientsHaveNoActionToTake = document.Recipient.every(
(recipient) => recipient.role === RecipientRole.CC,
);
if (allRecipientsHaveNoActionToTake) {
const updatedDocument = await updateDocument({
documentId,
userId,
teamId,
data: { status: DocumentStatus.COMPLETED },
});
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => { const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) { if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({

View File

@ -0,0 +1,29 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type GetCompletedFieldsForDocumentOptions = {
documentId: number;
};
export const getCompletedFieldsForDocument = async ({
documentId,
}: GetCompletedFieldsForDocumentOptions) => {
return await prisma.field.findMany({
where: {
documentId,
Recipient: {
signingStatus: SigningStatus.SIGNED,
},
inserted: true,
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
},
},
},
});
};

View File

@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type GetCompletedFieldsForTokenOptions = {
token: string;
};
export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => {
return await prisma.field.findMany({
where: {
Document: {
Recipient: {
some: {
token,
},
},
},
Recipient: {
signingStatus: SigningStatus.SIGNED,
},
inserted: true,
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
},
},
},
});
};

View File

@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
let browser: Browser; let browser: Browser;
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) { if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL); // !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
} else { } else {
browser = await chromium.launch(); browser = await chromium.launch();
} }
@ -33,6 +35,7 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, { await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle', waitUntil: 'networkidle',
timeout: 10_000,
}); });
const result = await page.pdf({ const result = await page.pdf({

View File

@ -1,3 +1,4 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client'; import { TeamMemberRole } from '@documenso/prisma/client';
@ -6,6 +7,8 @@ export type GetUserTokensOptions = {
teamId: number; teamId: number;
}; };
export type GetTeamTokensResponse = Awaited<ReturnType<typeof getTeamTokens>>;
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => { export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
const teamMember = await prisma.teamMember.findFirst({ const teamMember = await prisma.teamMember.findFirst({
where: { where: {
@ -15,7 +18,10 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) =>
}); });
if (teamMember?.role !== TeamMemberRole.ADMIN) { if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to view tokens for this team'); throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have the required permissions to view this page.',
);
} }
return await prisma.apiToken.findMany({ return await prisma.apiToken.findMany({

View File

@ -0,0 +1,144 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateLegacyOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
name?: string;
email: string;
role?: RecipientRole;
}[];
};
/**
* Legacy server function for /api/v1
*/
export const createDocumentFromTemplateLegacy = async ({
templateId,
userId,
teamId,
recipients,
}: CreateDocumentFromTemplateLegacyOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,
templateDocumentData: true,
},
});
if (!template) {
throw new Error('Template not found.');
}
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
data: template.templateDocumentData.data,
initialData: template.templateDocumentData.initialData,
},
});
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
if (!documentRecipient) {
throw new Error('Recipient not found.');
}
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
documentId: document.id,
recipientId: documentRecipient.id,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
}),
);
}
return document;
};

View File

@ -1,16 +1,29 @@
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client'; import type { Field } from '@documenso/prisma/client';
import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role'> & {
templateRecipientId: number;
fields: Field[];
};
export type CreateDocumentFromTemplateOptions = { export type CreateDocumentFromTemplateOptions = {
templateId: number; templateId: number;
userId: number; userId: number;
teamId?: number; teamId?: number;
recipients?: { recipients: {
id: number;
name?: string; name?: string;
email: string; email: string;
role?: RecipientRole;
}[]; }[];
requestMetadata?: RequestMetadata;
}; };
export const createDocumentFromTemplate = async ({ export const createDocumentFromTemplate = async ({
@ -18,7 +31,14 @@ export const createDocumentFromTemplate = async ({
userId, userId,
teamId, teamId,
recipients, recipients,
requestMetadata,
}: CreateDocumentFromTemplateOptions) => { }: CreateDocumentFromTemplateOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: { where: {
id: templateId, id: templateId,
@ -39,16 +59,42 @@ export const createDocumentFromTemplate = async ({
}), }),
}, },
include: { include: {
Recipient: true, Recipient: {
Field: true, include: {
Field: true,
},
},
templateDocumentData: true, templateDocumentData: true,
}, },
}); });
if (!template) { if (!template) {
throw new Error('Template not found.'); throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
} }
if (recipients.length !== template.Recipient.length) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid number of recipients.');
}
const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => {
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
if (!foundRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
`Missing template recipient with ID ${templateRecipient.id}`,
);
}
return {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient.name ?? '',
email: foundRecipient.email,
role: templateRecipient.role,
};
});
const documentData = await prisma.documentData.create({ const documentData = await prisma.documentData.create({
data: { data: {
type: template.templateDocumentData.type, type: template.templateDocumentData.type,
@ -57,81 +103,82 @@ export const createDocumentFromTemplate = async ({
}, },
}); });
const document = await prisma.document.create({ return await prisma.$transaction(async (tx) => {
data: { const document = await tx.document.create({
userId, data: {
teamId: template.teamId, userId,
title: template.title, teamId: template.teamId,
documentDataId: documentData.id, title: template.title,
Recipient: { documentDataId: documentData.id,
create: template.Recipient.map((recipient) => ({ Recipient: {
email: recipient.email, createMany: {
name: recipient.name, data: finalRecipients.map((recipient) => ({
role: recipient.role, email: recipient.email,
token: nanoid(), name: recipient.name,
})), role: recipient.role,
}, token: nanoid(),
}, })),
},
include: {
Recipient: {
orderBy: {
id: 'asc',
}, },
}, },
documentData: true, include: {
}, Recipient: {
}); orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({ let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.Recipient.find((recipient) => recipient.email === email);
return { if (!recipient) {
type: field.type, throw new Error('Recipient not found.');
page: field.page, }
positionX: field.positionX,
positionY: field.positionY, fieldsToCreate = fieldsToCreate.concat(
width: field.width, fields.map((field) => ({
height: field.height, documentId: document.id,
customText: field.customText, recipientId: recipient.id,
inserted: field.inserted, type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
})),
);
});
await tx.field.createMany({
data: fieldsToCreate,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id, documentId: document.id,
recipientId: documentRecipient?.id || null, user,
}; requestMetadata,
}), data: {
}); title: document.title,
},
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
}), }),
); });
}
return document; await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
return document;
});
}; };

View File

@ -81,6 +81,10 @@ export const duplicateTemplate = async ({
(doc) => doc.email === recipient?.email, (doc) => doc.email === recipient?.email,
); );
if (!duplicatedTemplateRecipient) {
throw new Error('Recipient not found.');
}
return { return {
type: field.type, type: field.type,
page: field.page, page: field.page,
@ -91,7 +95,7 @@ export const duplicateTemplate = async ({
customText: field.customText, customText: field.customText,
inserted: field.inserted, inserted: field.inserted,
templateId: duplicatedTemplate.id, templateId: duplicatedTemplate.id,
recipientId: duplicatedTemplateRecipient?.id || null, recipientId: duplicatedTemplateRecipient.id,
}; };
}), }),
}); });

View File

@ -5,9 +5,10 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
export type UpdatePublicProfileOptions = { export type UpdatePublicProfileOptions = {
userId: number; userId: number;
url: string; url: string;
bio?: string;
}; };
export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => { export const updatePublicProfile = async ({ userId, url, bio }: UpdatePublicProfileOptions) => {
const isUrlTaken = await prisma.user.findFirst({ const isUrlTaken = await prisma.user.findFirst({
select: { select: {
id: true, id: true,
@ -37,10 +38,10 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
userProfile: { userProfile: {
upsert: { upsert: {
create: { create: {
bio: '', bio: bio,
}, },
update: { update: {
bio: '', bio: bio,
}, },
}, },
}, },

View File

@ -4,6 +4,7 @@ export const getWebhooksByUserId = async (userId: number) => {
return await prisma.webhook.findMany({ return await prisma.webhook.findMany({
where: { where: {
userId, userId,
teamId: null,
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',

View File

@ -0,0 +1,3 @@
import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token';
export type CompletedField = Awaited<ReturnType<typeof getCompletedFieldsForToken>>[number];

View File

@ -17,6 +17,7 @@ export const getFlag = async (
options?: GetFlagOptions, options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => { ): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {}; const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) { if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true; return LOCAL_FEATURE_FLAGS[flag] ?? true;
@ -25,7 +26,7 @@ export const getFlag = async (
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`); const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag); url.searchParams.set('flag', flag);
const response = await fetch(url, { return await fetch(url, {
headers: { headers: {
...requestHeaders, ...requestHeaders,
}, },
@ -35,9 +36,10 @@ export const getFlag = async (
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res)) .then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false); .catch((err) => {
console.error(err);
return response; return LOCAL_FEATURE_FLAGS[flag] ?? false;
});
}; };
/** /**
@ -50,6 +52,7 @@ export const getAllFlags = async (
options?: GetFlagOptions, options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => { ): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {}; const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) { if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS; return LOCAL_FEATURE_FLAGS;
@ -67,7 +70,10 @@ export const getAllFlags = async (
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS); .catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
}; };
/** /**
@ -89,7 +95,10 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS); .catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
}; };
interface GetFlagOptions { interface GetFlagOptions {

View File

@ -1,9 +1,12 @@
import { base64 } from '@scure/base'; import { base64 } from '@scure/base';
import { env } from 'next-runtime-env'; import { env } from 'next-runtime-env';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { DocumentDataType } from '@documenso/prisma/client'; import { DocumentDataType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data'; import { createDocumentData } from '../../server-only/document-data/create-document-data';
type File = { type File = {
@ -12,14 +15,38 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>; arrayBuffer: () => Promise<ArrayBuffer>;
}; };
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
export const putPdfFile = async (file: File) => {
const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch(
() => false,
);
// This will prevent uploading encrypted PDFs or anything that can't be opened.
if (!isEncryptedDocumentsAllowed) {
await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
}
const { type, data } = await putFile(file);
return await createDocumentData({ type, data });
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => { export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT) return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file)) .with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file)); .otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
}; };
const putFileInDatabase = async (file: File) => { const putFileInDatabase = async (file: File) => {

View File

@ -0,0 +1,11 @@
/*
Warnings:
- Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column.
*/
-- Drop all Fields where the recipientId is null
DELETE FROM "Field" WHERE "recipientId" IS NULL;
-- AlterTable
ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL;

View File

@ -387,7 +387,7 @@ model Field {
secondaryId String @unique @default(cuid()) secondaryId String @unique @default(cuid())
documentId Int? documentId Int?
templateId Int? templateId Int?
recipientId Int? recipientId Int
type FieldType type FieldType
page Int page Int
positionX Decimal @default(0) positionX Decimal @default(0)
@ -398,7 +398,7 @@ model Field {
inserted Boolean inserted Boolean
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade) Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Signature Signature? Signature Signature?
@@index([documentId]) @@index([documentId])

View File

@ -4,6 +4,7 @@ import { DateTime } from 'luxon';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError } from '@documenso/lib/errors/app-error';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
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';
@ -20,6 +21,7 @@ import { updateDocumentSettings } from '@documenso/lib/server-only/document/upda
import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus } from '@documenso/prisma/client';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -221,10 +223,6 @@ export const documentRouter = router({
} }
}), }),
getDocumentMetaById: authenticatedProcedure
.input(ZSetSettingsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {}),
setTitleForDocument: authenticatedProcedure setTitleForDocument: authenticatedProcedure
.input(ZSetTitleForDocumentMutationSchema) .input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@ -417,6 +415,10 @@ export const documentRouter = router({
teamId, teamId,
}); });
if (document.status !== DocumentStatus.COMPLETED) {
throw new AppError('DOCUMENT_NOT_COMPLETE');
}
const encrypted = encryptSecondaryData({ const encrypted = encryptSecondaryData({
data: document.id.toString(), data: document.id.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),

View File

@ -88,9 +88,9 @@ export const profileRouter = router({
.input(ZUpdatePublicProfileMutationSchema) .input(ZUpdatePublicProfileMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { url } = input; const { url, bio } = input;
if (IS_BILLING_ENABLED() && url.length <= 6) { if (IS_BILLING_ENABLED() && url.length < 6) {
const subscriptions = await getSubscriptionsByUserId({ const subscriptions = await getSubscriptionsByUserId({
userId: ctx.user.id, userId: ctx.user.id,
}).then((subscriptions) => }).then((subscriptions) =>
@ -108,6 +108,7 @@ export const profileRouter = router({
const user = await updatePublicProfile({ const user = await updatePublicProfile({
userId: ctx.user.id, userId: ctx.user.id,
url, url,
bio,
}); });
return { success: true, url: user.url }; return { success: true, url: user.url };

View File

@ -25,6 +25,13 @@ export const ZUpdatePublicProfileMutationSchema = z.object({
.regex(/^[a-z0-9-]+$/, { .regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.', message: 'Username can only container alphanumeric characters and dashes.',
}), }),
bio: z
.string()
.trim()
.max(256, {
message: 'Bio cannot be longer than 256 characters.',
})
.optional(),
}); });
export const ZUpdatePasswordMutationSchema = z.object({ export const ZUpdatePasswordMutationSchema = z.object({

View File

@ -10,7 +10,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id'; import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentStatus, DocumentStatus,
@ -86,7 +86,7 @@ export const singleplayerRouter = router({
}, },
}); });
const { id: documentDataId } = await putFile({ const { id: documentDataId } = await putPdfFile({
name: `${documentName}.pdf`, name: `${documentName}.pdf`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer), arrayBuffer: async () => Promise.resolve(signedPdfBuffer),

View File

@ -1,10 +1,14 @@
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 { AppError } from '@documenso/lib/errors/app-error';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template'; import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Document } from '@documenso/prisma/client';
import { authenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, router } from '../trpc';
import { import {
@ -49,19 +53,34 @@ export const templateRouter = router({
throw new Error('You have reached your document limit.'); throw new Error('You have reached your document limit.');
} }
return await createDocumentFromTemplate({ const requestMetadata = extractNextApiRequestMetadata(ctx.req);
let document: Document = await createDocumentFromTemplate({
templateId, templateId,
teamId, teamId,
userId: ctx.user.id, userId: ctx.user.id,
recipients: input.recipients, recipients: input.recipients,
requestMetadata,
}); });
if (input.sendDocument) {
document = await sendDocument({
documentId: document.id,
userId: ctx.user.id,
teamId,
requestMetadata,
}).catch((err) => {
console.error(err);
throw new AppError('DOCUMENT_SEND_FAILED');
});
}
return document;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
throw new TRPCError({ throw AppError.parseErrorToTRPCError(err);
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
} }
}), }),

View File

@ -1,7 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { RecipientRole } from '@documenso/prisma/client';
export const ZCreateTemplateMutationSchema = z.object({ export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(), title: z.string().min(1).trim(),
teamId: z.number().optional(), teamId: z.number().optional(),
@ -14,12 +12,16 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
recipients: z recipients: z
.array( .array(
z.object({ z.object({
id: z.number(),
email: z.string().email(), email: z.string().email(),
name: z.string(), name: z.string().optional(),
role: z.nativeEnum(RecipientRole),
}), }),
) )
.optional(), .refine((recipients) => {
const emails = recipients.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
sendDocument: z.boolean().optional(),
}); });
export const ZDuplicateTemplateMutationSchema = z.object({ export const ZDuplicateTemplateMutationSchema = z.object({

View File

@ -19,6 +19,7 @@ export type FieldContainerPortalProps = {
field: Field; field: Field;
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
cardClassName?: string;
}; };
export function FieldContainerPortal({ export function FieldContainerPortal({
@ -44,7 +45,7 @@ export function FieldContainerPortal({
); );
} }
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) { export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp
{ {
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating, 'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
}, },
cardClassName,
)} )}
ref={ref} ref={ref}
data-inserted={field.inserted ? 'true' : 'false'} data-inserted={field.inserted ? 'true' : 'false'}

View File

@ -0,0 +1,80 @@
'use client';
import React from 'react';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { RecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientActionAuthSelectProps = SelectProps;
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => {
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>Recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign fields</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</li>
{/* <li>
<strong>Require account</strong> - The recipient must be
signed in
</li> */}
<li>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@ -0,0 +1,97 @@
'use client';
import React from 'react';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { RecipientRole } from '@documenso/prisma/client';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientRoleSelectProps = SelectProps;
export const RecipientRoleSelect = (props: RecipientRoleSelectProps) => {
return (
<Select {...props}>
<SelectTrigger className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[props.value as RecipientRole]}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Needs to sign
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to sign the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to approve the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to view the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is not required to take any action and receives a copy of the
document after it is completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectContent>
</Select>
);
};

View File

@ -9,7 +9,11 @@ import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth'; import {
DocumentAccessAuth,
DocumentActionAuth,
DocumentAuth,
} from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
@ -216,9 +220,9 @@ export const AddSettingsFormPartial = ({
</p> </p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2"> <ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li> {/* <li>
<strong>Require account</strong> - The recipient must be signed in <strong>Require account</strong> - The recipient must be signed in
</li> </li> */}
<li> <li>
<strong>Require passkey</strong> - The recipient must have an account <strong>Require passkey</strong> - The recipient must have an account
and passkey configured via their settings and passkey configured via their settings
@ -242,11 +246,13 @@ export const AddSettingsFormPartial = ({
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
{Object.values(DocumentActionAuth).map((authType) => ( {Object.values(DocumentActionAuth)
<SelectItem key={authType} value={authType}> .filter((auth) => auth !== DocumentAuth.ACCOUNT)
{DOCUMENT_AUTH_TYPES[authType].value} .map((authType) => (
</SelectItem> <SelectItem key={authType} value={authType}>
))} {DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */} {/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem> <SelectItem value={'-1'}>None</SelectItem>

View File

@ -28,8 +28,6 @@ export const ZAddSettingsFormSchema = z.object({
ZDocumentActionAuthTypesSchema.optional(), ZDocumentActionAuthTypesSchema.optional(),
), ),
meta: z.object({ meta: z.object({
subject: z.string().optional(),
message: z.string().optional(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z redirectUrl: z

View File

@ -4,20 +4,18 @@ import React, { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react'; import { Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import {
RecipientActionAuth,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole, SendStatus } from '@documenso/prisma/client'; import { RecipientRole, SendStatus } from '@documenso/prisma/client';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '../button'; import { Button } from '../button';
@ -25,10 +23,7 @@ import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input'; import { Input } from '../input';
import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast'; import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types'; import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types'; import { ZAddSignersFormSchema } from './add-signers.types';
@ -274,65 +269,11 @@ export const AddSignersFormPartial = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-6"> <FormItem className="col-span-6">
<FormControl> <FormControl>
<Select <RecipientActionAuthSelect
{...field} {...field}
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)} disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
> />
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>Recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign fields</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Inherit authentication method</strong> - Use the
global action signing authentication method configured in
the "General Settings" step
</li>
<li>
<strong>Require account</strong> - The recipient must be
signed in
</li>
<li>
<strong>Require passkey</strong> - The recipient must have
an account and passkey configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an
account and 2FA enabled via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -346,100 +287,11 @@ export const AddSignersFormPartial = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-1 mt-auto"> <FormItem className="col-span-1 mt-auto">
<FormControl> <FormControl>
<Select <RecipientRoleSelect
{...field} {...field}
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)} disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
> />
<SelectTrigger className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[field.value as RecipientRole]}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Needs to sign
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to sign the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">
{ROLE_ICONS[RecipientRole.APPROVER]}
</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to approve the document for it to
be completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to view the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is not required to take any action and
receives a copy of the document after it is completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectContent>
</Select>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -32,8 +30,6 @@ export type AddSubjectFormProps = {
document: DocumentWithData; document: DocumentWithData;
onSubmit: (_data: TAddSubjectFormSchema) => void; onSubmit: (_data: TAddSubjectFormSchema) => void;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setSubjectFormFields: (subject?: string, message?: string) => void;
}; };
export const AddSubjectFormPartial = ({ export const AddSubjectFormPartial = ({
@ -43,12 +39,10 @@ export const AddSubjectFormPartial = ({
document, document,
onSubmit, onSubmit,
isDocumentPdfLoaded, isDocumentPdfLoaded,
setSubjectFormFields,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { const {
register, register,
handleSubmit, handleSubmit,
getValues,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({ } = useForm<TAddSubjectFormSchema>({
defaultValues: { defaultValues: {
@ -63,13 +57,6 @@ export const AddSubjectFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep(); const { currentStep, totalSteps, previousStep } = useStep();
useEffect(() => {
return () => {
const { meta } = getValues();
setSubjectFormFields(meta.subject, meta.message);
};
}, [getValues, setSubjectFormFields]);
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader

View File

@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef<
PopoverContent.displayName = PopoverPrimitive.Content.displayName; PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent }; type PopoverHoverProps = {
trigger: React.ReactNode;
children: React.ReactNode;
contentProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>;
};
const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => {
const [open, setOpen] = React.useState(false);
const isControlled = React.useRef(false);
const isMouseOver = React.useRef<boolean>(false);
const onMouseEnter = () => {
isMouseOver.current = true;
if (isControlled.current) {
return;
}
setOpen(true);
};
const onMouseLeave = () => {
isMouseOver.current = false;
if (isControlled.current) {
return;
}
setTimeout(() => {
setOpen(isMouseOver.current);
}, 200);
};
const onOpenChange = (newOpen: boolean) => {
isControlled.current = newOpen;
setOpen(newOpen);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
className="flex cursor-pointer outline-none"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{trigger}
</PopoverTrigger>
<PopoverContent
side="top"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...contentProps}
>
{children}
</PopoverContent>
</Popover>
);
};
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };

View File

@ -1,20 +1,25 @@
'use client'; 'use client';
import React, { useId, useState } from 'react'; import React, { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react'; import { Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Checkbox } from '../checkbox';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
@ -22,10 +27,8 @@ import {
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root'; } from '../document-flow/document-flow-root';
import type { DocumentFlowStep } from '../document-flow/types'; import type { DocumentFlowStep } from '../document-flow/types';
import { ROLE_ICONS } from '../recipient-role-icons'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
@ -33,30 +36,29 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
isTemplateOwnerEnterprise: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
}; };
export const AddTemplatePlaceholderRecipientsFormPartial = ({ export const AddTemplatePlaceholderRecipientsFormPartial = ({
documentFlow, documentFlow,
isTemplateOwnerEnterprise,
recipients, recipients,
fields: _fields, fields: _fields,
onSubmit, onSubmit,
}: AddTemplatePlaceholderRecipientsFormProps) => { }: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId(); const initialId = useId();
const { data: session } = useSession(); const { data: session } = useSession();
const user = session?.user; const user = session?.user;
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() => const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
recipients.length > 1 ? recipients.length + 1 : 2, recipients.length > 1 ? recipients.length + 1 : 2,
); );
const { currentStep, totalSteps, previousStep } = useStep(); const { currentStep, totalSteps, previousStep } = useStep();
const { const form = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
control,
handleSubmit,
getValues,
formState: { errors, isSubmitting },
} = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema),
defaultValues: { defaultValues: {
signers: signers:
@ -67,6 +69,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: recipient.name, name: recipient.name,
email: recipient.email, email: recipient.email,
role: recipient.role, role: recipient.role,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
})) }))
: [ : [
{ {
@ -74,12 +78,33 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: `Recipient 1`, name: `Recipient 1`,
email: `recipient.1@documenso.com`, email: `recipient.1@documenso.com`,
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: undefined,
}, },
], ],
}, },
}); });
const onFormSubmit = handleSubmit(onSubmit); // Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const {
formState: { errors, isSubmitting },
control,
} = form;
const onFormSubmit = form.handleSubmit(onSubmit);
const { const {
append: appendSigner, append: appendSigner,
@ -102,7 +127,9 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const onAddPlaceholderRecipient = () => { const onAddPlaceholderRecipient = () => {
appendSigner({ appendSigner({
formId: nanoid(12), formId: nanoid(12),
// Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed.
name: `Recipient ${placeholderRecipientCount}`, name: `Recipient ${placeholderRecipientCount}`,
// Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed.
email: `recipient.${placeholderRecipientCount}@documenso.com`, email: `recipient.${placeholderRecipientCount}@documenso.com`,
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
}); });
@ -117,180 +144,176 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
return ( return (
<> <>
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
<div className="flex w-full flex-col gap-y-4"> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<AnimatePresence> <Form {...form}>
{signers.map((signer, index) => ( <div className="flex w-full flex-col gap-y-2">
<motion.div {signers.map((signer, index) => (
key={signer.id} <motion.fieldset
data-native-id={signer.nativeId} key={signer.id}
className="flex flex-wrap items-end gap-x-4" data-native-id={signer.nativeId}
> disabled={isSubmitting}
<div className="flex-1"> className={cn('grid grid-cols-8 gap-4 pb-4', {
<Label htmlFor={`signer-${signer.id}-email`}>Email</Label> 'border-b pt-2': showAdvancedSettings,
})}
>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>Email</FormLabel>
)}
<Input <FormControl>
id={`signer-${signer.id}-email`} <Input
type="email" type="email"
value={signer.email} placeholder="Email"
disabled {...field}
className="bg-background mt-2" disabled={isSubmitting || signers[index].email === user?.email}
/> />
</div> </FormControl>
<div className="flex-1"> <FormMessage />
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label> </FormItem>
)}
<Input />
id={`signer-${signer.id}-name`}
type="text" <FormField
value={signer.name} control={form.control}
disabled name={`signers.${index}.name`}
className="bg-background mt-2" render={({ field }) => (
/> <FormItem
</div> className={cn({
'col-span-3': !showAdvancedSettings,
<div className="w-[60px]"> 'col-span-4': showAdvancedSettings,
<Controller })}
control={control} >
name={`signers.${index}.role`} {!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}> <FormControl>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger> <Input
placeholder="Name"
<SelectContent className="" align="end"> {...field}
<SelectItem value={RecipientRole.SIGNER}> disabled={isSubmitting || signers[index].email === user?.email}
<div className="flex items-center"> />
<div className="flex w-[150px] items-center"> </FormControl>
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Needs to sign <FormMessage />
</div> </FormItem>
<Tooltip> )}
<TooltipTrigger> />
<InfoIcon className="h-4 w-4" />
</TooltipTrigger> {showAdvancedSettings && isTemplateOwnerEnterprise && (
<TooltipContent className="text-foreground z-9999 max-w-md p-4"> <FormField
<p> control={form.control}
The recipient is required to sign the document for it to be name={`signers.${index}.actionAuth`}
completed. render={({ field }) => (
</p> <FormItem className="col-span-6">
</TooltipContent> <FormControl>
</Tooltip> <RecipientActionAuthSelect
</div> {...field}
</SelectItem> onValueChange={field.onChange}
disabled={isSubmitting}
<SelectItem value={RecipientRole.APPROVER}> />
<div className="flex items-center"> </FormControl>
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span> <FormMessage />
Needs to approve </FormItem>
</div> )}
<Tooltip> />
<TooltipTrigger> )}
<InfoIcon className="h-4 w-4" />
</TooltipTrigger> <FormField
<TooltipContent className="text-foreground z-9999 max-w-md p-4"> name={`signers.${index}.role`}
<p> render={({ field }) => (
The recipient is required to approve the document for it to be <FormItem className="col-span-1 mt-auto">
completed. <FormControl>
</p> <RecipientRoleSelect
</TooltipContent> {...field}
</Tooltip> onValueChange={field.onChange}
</div> disabled={isSubmitting}
</SelectItem> />
</FormControl>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center"> <FormMessage />
<div className="flex w-[150px] items-center"> </FormItem>
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to view the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is not required to take any action and receives a
copy of the document after it is completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectContent>
</Select>
)} )}
/> />
</div>
<div>
<button <button
type="button" type="button"
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1} disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)} onClick={() => onRemoveSigner(index)}
> >
<Trash className="h-5 w-5" /> <Trash className="h-5 w-5" />
</button> </button>
</div> </motion.fieldset>
))}
</div>
<div className="w-full"> <FormErrorMessage
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} /> className="mt-2"
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} /> // Dirty hack to handle errors when .root is populated for an array type
</div> error={'signers__root' in errors && errors['signers__root']}
</motion.div> />
))}
</AnimatePresence>
</div>
<FormErrorMessage <div
className="mt-2" className={cn('mt-2 flex flex-row items-center space-x-4', {
// Dirty hack to handle errors when .root is populated for an array type 'mt-4': showAdvancedSettings,
error={'signers__root' in errors && errors['signers__root']} })}
/> >
<Button
type="button"
className="flex-1"
disabled={isSubmitting}
onClick={() => onAddPlaceholderRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Placeholder Recipient
</Button>
<div className="mt-4 flex flex-row items-center space-x-4"> <Button
<Button type="button"
type="button" className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
className="flex-1" variant="secondary"
disabled={isSubmitting} disabled={
onClick={() => onAddPlaceholderRecipient()} isSubmitting ||
> form.getValues('signers').some((signer) => signer.email === user?.email)
<Plus className="-ml-1 mr-2 h-5 w-5" /> }
Add Placeholder Recipient onClick={() => onAddPlaceholderSelfRecipient()}
</Button> >
<Button <Plus className="-ml-1 mr-2 h-5 w-5" />
type="button" Add Myself
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10" </Button>
disabled={ </div>
isSubmitting || getValues('signers').some((signer) => signer.email === user?.email)
} {!alwaysShowAdvancedSettings && isTemplateOwnerEnterprise && (
onClick={() => onAddPlaceholderSelfRecipient()} <div className="mt-4 flex flex-row items-center">
> <Checkbox
<Plus className="-ml-1 mr-2 h-5 w-5" /> id="showAdvancedRecipientSettings"
Add Myself className="h-5 w-5"
</Button> checkClassName="dark:text-white text-primary"
</div> checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings"
>
Show advanced settings
</label>
</div>
)}
</Form>
</AnimateGenericFadeInOut>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter> <DocumentFlowFormContainerFooter>

View File

@ -1,5 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { RecipientRole } from '.prisma/client'; import { RecipientRole } from '.prisma/client';
export const ZAddTemplatePlacholderRecipientsFormSchema = z export const ZAddTemplatePlacholderRecipientsFormSchema = z
@ -11,6 +14,9 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
email: z.string().min(1).email(), email: z.string().min(1).email(),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}), }),
), ),
}) })