Compare commits

..

124 Commits

Author SHA1 Message Date
Lucas Smith
15ebe6dbaf fix: add cascade delete to recipient fields 2024-02-26 03:51:28 +00:00
Lucas Smith
f7c6b53258 feat: public api start (#674)
This PR adds the public-facing API.

![API high-level
workings](https://github.com/documenso/documenso/assets/25515812/9f866889-164f-401f-bf4e-d3e69b6382f1)

The public API will allow users to interact with Documenso
programmatically. At the moment, there are 5 available endpoints:
1. `GET /documents`
    - get all documents of the user making the API call
- it accepts 2 URL query parameters - `page` and `perPage`, but they are
not mandatory
2. `GET /documents/:id`
    - get a specific document of the user making the API call
- it requires 1 URL parameter, which represents the ID of the document
that needs to be retrieved
3. `DELETE /documents/:id`
    - delete a specific document of the user making the API call
- it requires 1 URL parameter, which represents the ID of the document
that needs to be deleted
4. `POST /documents`
- making a POST request to this endpoint will return an S3 pre-signed
URL where you can upload the document
    - it requires you to pass the file name and the file content type
5. `PATCH /documents/:id/send-for-signing`
    - send a document for signing
    - it allows you to pass the following:
        - signer email *(required)*
        - signer name *(optional)*
        - field type (signature, email, name, etc.) *(required)*
        - page number where to insert the field *(required)*
        - page X *(required)*
        - page Y *(required)*
        - page width *(required)*
        - page height *(required)*
        - email subject *(optional)*
        - email body *(optional)*

The users are authenticated through API tokens. The users can create one
or more tokens in their account settings, and each token will be
available for 1 year _(duration open to suggestions/changes)_.

Each time the user makes a request, the app checks if the token exists
and if it's valid. The app will return `401` *(unauthorized)* and the
appropriate error message if either is false. If both are true AND the
user uses the correct HTTP verb and passes the required URL parameters
and body, the API call will be successful.

Code overview:
- `@documenso/packages/lib/trpc/api-contract` - this folder contains the
`contract` and `schema` files. The `contract.ts` file describes the
structure of the API, the format of the requests and responses, and how
to authenticate your API calls, among others. The `schema.ts` file
contains the Zod schemas used in the API contract.
- `@documenso/packages/trpc/server/api-token-router` - this folder
contains the `router.rs` and `schema.ts` files. Here are the tRPC
procedures used on the frontend. That is, the user's settings page,
where the user can list/create/delete API tokens.
- `@documenso/packages/trpc/tsconfig.json` - I added `"strict": true`
because that's what's suggested in the `ts-rest` documentation.
*([source](https://ts-rest.com/docs/quickstart#create-a-contract))*
- `[...ts-rest].tsx` - This file contains the implementation of the API.
You can see the logic behind each route.
- the rest of the code represents the code for the API tokens page in
the user's settings.
2024-02-26 12:41:16 +11:00
Mythie
4778270d3c fix: use template dialog to pass teamId 2024-02-26 12:20:50 +11:00
Lucas Smith
3c51a1bc3d Merge branch 'main' into feat/public-api 2024-02-26 12:15:33 +11:00
Lucas Smith
fcfb741363 feat: edit recipients when creating document from template (#953)
https://github.com/documenso/documenso/assets/55143799/85a840e3-4fb4-4c02-ba63-b9626f4cea58
2024-02-26 12:00:23 +11:00
Mythie
a54159a9ec fix: add missing teamId references 2024-02-26 11:59:32 +11:00
David Nguyen
5bd0dde4a5 fix: e2e tests 2024-02-26 10:50:46 +11:00
Mythie
6b8e11b535 fix: template router update 2024-02-26 10:31:24 +11:00
Mythie
c7dac2f4de fix: update delete endpoint and add limits 2024-02-26 10:01:13 +11:00
Mythie
4cb0d6999d fix: build errors from merge 2024-02-26 09:13:46 +11:00
Lucas Smith
2a74ce06ef Merge branch 'main' into feat/public-api 2024-02-26 00:21:25 +11:00
Lucas Smith
6ea6dda99d feat: add transparent background to fields (#899) 2024-02-26 00:10:35 +11:00
Lucas Smith
187a988f9c Merge branch 'main' into transparent-fields 2024-02-26 00:02:56 +11:00
Lucas Smith
5bef2fba91 fix: follow figma design 2024-02-25 13:02:09 +00:00
Lucas Smith
df3ba11655 feat: account deletion (#846)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
	- Added two-factor authentication form handling in profile settings.
- Introduced account deletion functionality, including Stripe customer
data removal.
- Updated UI components and styles for improved user interaction, such
as enhanced visual feedback for destructive actions.
- **Refactor**
- Improved code quality by updating import statements for type-only
imports.
- **Chores**
- Restructured the `forwardPorts` array in dev container configuration
for clarity.
- Removed a trailing comma in the `vscode` extensions list to adhere to
JSON format rules.
- **Documentation**
- Added comments to clarify the handling of undefined values in
two-factor authentication verification.
- **Database**
- Implemented a SQL migration script for creating a default deleted user
in the system.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-02-25 22:49:55 +11:00
Lucas Smith
9cf72e1442 chore: tidy code and extract alert-dialog 2024-02-25 11:12:18 +00:00
Lucas Smith
7226d5ac53 Merge branch 'main' into feat/account-deletion 2024-02-24 23:18:14 +11:00
Lucas Smith
dbd63498e0 feat: custom text field (#945)
https://github.com/documenso/documenso/assets/55143799/c0f296ce-1e91-4b49-9c19-1bdffbd3163f
2024-02-24 22:55:16 +11:00
Lucas Smith
4ca154d88a fix: update e2e test? 2024-02-24 11:54:13 +00:00
Lucas Smith
d34c862688 fix: switch custom text to input rather than textarea 2024-02-24 11:25:12 +00:00
Lucas Smith
a5e51e63a1 Merge branch 'main' into custom-text-field 2024-02-24 22:23:17 +11:00
Lucas Smith
14c77d7c92 fix: update e2e test 2024-02-24 11:19:07 +00:00
Lucas Smith
a5a0fd9187 fix: update e2e test 2024-02-24 10:55:48 +00:00
Lucas Smith
77193b93c4 fix: resolve build error and update dialog width 2024-02-24 10:36:27 +00:00
Lucas Smith
a10957d909 Merge branch 'main' into feat/improve-create-document-from-template 2024-02-24 21:26:11 +11:00
Lucas Smith
688e32dfc2 feat: add roles to templates recipients (#935) 2024-02-24 21:24:26 +11:00
Lucas Smith
b1b3b84d82 Merge branch 'main' into feat/template-recipient-roles 2024-02-24 21:01:45 +11:00
Lucas Smith
b09acaf94e feat: custom banner (#956) 2024-02-24 20:40:41 +11:00
Lucas Smith
d103c68a73 fix: remove console logs 2024-02-24 08:08:14 +00:00
David Nguyen
a06b40af3c fix: revert hardcoded env 2024-02-24 15:11:12 +11:00
David Nguyen
0900836040 fix: missing marketing env variables 2024-02-24 14:56:11 +11:00
Timur Ercan
0b83671c78 fix: hardcode signup until env issue is fixed (#958)
hotfix for broken signup links
2024-02-23 16:50:43 +01:00
Timur Ercan
41cbf3ba3c fix: hardcode signup until env issue is fixed 2024-02-23 16:49:17 +01:00
Lucas Smith
8165a090d1 feat: migrate to site-settings 2024-02-23 10:47:01 +00:00
Ephraim Atta-Duncan
c436559787 feat: create a banner with custom text by admin 2024-02-22 20:20:49 +00:00
Lucas Smith
15e191f62d fix: e2e tests (#955) 2024-02-22 23:58:51 +11:00
Lucas Smith
f973dfd09c feat: add document page view (#926)
## Description

This feature allows us to have a dedicated page to view details for a
document.

The current `/documents/[id]` page has been moved to
`/documents/[id]/edit`.

The new `/documents/[id]` page will now display general information on
the document itself.

This will allow us to provide more details relating to a document in the
future, such as version history.

## Examples

These may be outdated as deployment failed

Login via example@documenso.com

Draft document no recipients:

https://stg-7hpkvyc5i-documenso.vercel.app/documents/446

Draft document with recipients:

https://stg-7hpkvyc5i-documenso.vercel.app/documents/440

Pending document:

https://stg-7hpkvyc5i-documenso.vercel.app/documents/447

Completed document:

https://stg-7hpkvyc5i-documenso.vercel.app/documents/448

Pending teams document:

https://stg-7hpkvyc5i-documenso.vercel.app/t/hello/documents/449/

## Checklist

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

- [ ] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [X] I have followed the project's coding style guidelines.

## Additional Notes

When a user "sends" a document (DRAFT -> PENDING), should we redirect
them to the document/id page, or keep it how it currently is and have it
redirect to `/documents` page?
2024-02-22 23:25:22 +11:00
David Nguyen
a58fee2da6 fix: e2e tests 2024-02-22 22:58:51 +11:00
Lucas Smith
1232f54a23 Merge branch 'main' into feat/separate-document-page 2024-02-22 22:54:15 +11:00
Lucas Smith
306e5ff31f fix: add timeouts to longer transactions 2024-02-22 11:05:49 +00:00
David Nguyen
34825aaf3a feat: add separate document audit page 2024-02-22 19:14:01 +11:00
Sumit Bisht
dd29845934 fix: put max height in teams section in profile dropdown (#947)
fixes: #942
2024-02-22 16:49:54 +11:00
Ephraim Duncan
a48bda0d27 feat: add page numbers to documents (#946) 2024-02-22 16:36:34 +11:00
David Nguyen
48321f2f62 Merge branch 'main' into feat/separate-document-page 2024-02-22 16:13:33 +11:00
Mythie
2abcdd7533 feat: team api tokens 2024-02-22 13:39:34 +11:00
Lucas Smith
df132a51ab feat: ability to download all the 2FA recovery codes (#944)
fixes #938 



https://github.com/documenso/documenso/assets/81948346/8bf807ae-41b0-417b-9f55-d96584e71acb
2024-02-21 13:39:08 +11:00
Lucas Smith
8287722f59 fix: update view dialog to use new download api 2024-02-21 02:29:19 +00:00
Lucas Smith
aba6b58c14 fix: simplify download api 2024-02-21 02:19:35 +00:00
Lucas Smith
b6c9213b66 fix: disable static generation of marketing site pages
Disables the generation of the blog and content pages using
generateStaticPaths to deal with a regression with routing
introduced with next-runtime-env.
2024-02-21 00:58:57 +00:00
Lucas Smith
22e3a79a72 Merge branch 'main' into feat/public-api 2024-02-21 11:29:36 +11:00
Ephraim Atta-Duncan
6ee896048e feat: dialog to enter custom recipients for creating document from template 2024-02-20 19:11:12 +00:00
Mythie
4d286e01d1 feat: allow recipients when creating document from template 2024-02-20 17:20:03 +00:00
Mythie
b9e5905469 feat: create from template 2024-02-20 19:46:18 +11:00
Anik Dhabal Babu
39c6cbf66a feat: ability to download 2FA recovery codes 2024-02-19 11:25:15 +00:00
David Nguyen
ac6da9ab45 feat: add feature flag 2024-02-19 14:31:26 +11:00
David Nguyen
791a22cb5f refactor: add comments 2024-02-19 13:22:48 +11:00
Ephraim Atta-Duncan
11299d3f92 feat: add custom text to singleplayer 2024-02-17 11:24:47 +00:00
Ephraim Atta-Duncan
9301b8ef4d fix: multiple text fields sharing the same text value 2024-02-17 10:50:19 +00:00
Ephraim Atta-Duncan
ad6fad1182 fix: custom text with multiline throwing an error
took me hours
2024-02-17 10:43:22 +00:00
Ephraim Atta-Duncan
5687503dfc feat: sign document with a custom text 2024-02-17 08:26:30 +00:00
Anik Dhabal Babu
0186f2dfed feat: ability to download 2FA recovery codes 2024-02-17 13:19:03 +05:30
Ephraim Atta-Duncan
f98567ea87 feat: request usee to disable 2fa before deleting account 2024-02-17 07:34:21 +00:00
Anik Dhabal Babu
5d6cdbef89 feat: ability to download all the 2FA recovery codes 2024-02-16 20:46:27 +00:00
Ephraim Atta-Duncan
fddd860d15 chore: code refactor to avoid repetitions 2024-02-15 11:33:43 +00:00
David Nguyen
7fbe9b519c fix: styling 2024-02-15 20:42:17 +11:00
David Nguyen
bd3c64658a feat: add document version history UI 2024-02-15 20:39:26 +11:00
Ephraim Atta-Duncan
769eaa0ed9 feat: add roles to templates recipients 2024-02-15 07:01:41 +00:00
Ephraim Atta-Duncan
c680cfc24f chore: update pr based on review 2024-02-14 14:55:46 +00:00
Ephraim Atta-Duncan
cab875f68a fix: update create delete user sql script 2024-02-14 14:55:46 +00:00
Ephraim Duncan
6daaa3a6d4 Merge branch 'main' into feat/account-deletion 2024-02-14 14:54:26 +00:00
Mythie
4c5b910a59 chore: add examples 2024-02-14 13:15:35 +11:00
David Nguyen
524a2f4ea9 Merge branch 'main' into feat/separate-document-page 2024-02-13 11:15:31 +11:00
David Nguyen
82b87739d0 fix: update document links 2024-02-12 19:00:47 +11:00
David Nguyen
071475769c feat: add document page view 2024-02-12 17:30:23 +11:00
Mythie
1a82740d0f feat: support recipient roles 2024-02-12 15:16:09 +11:00
Catalin Pit
b3ba77dfed feat: allow user to choose expiry date 2024-02-09 11:35:09 +02:00
Lucas Smith
e91bb78f2d Merge branch 'main' into feat/public-api 2024-02-09 16:00:40 +11:00
Mythie
98df273ebc feat: add field and recipient endpoints 2024-02-08 16:58:44 +11:00
Adithya Krishna
7ef771533a Merge branch 'main' into transparent-fields 2024-02-07 13:34:46 +05:30
Ephraim Atta-Duncan
30752815e7 feat: soft-delete transfered documents 2024-02-05 13:06:36 +00:00
Ephraim Duncan
bc989075ba Merge branch 'main' into feat/account-deletion 2024-02-05 13:01:59 +00:00
Ephraim Atta-Duncan
4c09867b55 feat: transfer completed and pending documents to deleted email 2024-02-05 12:47:22 +00:00
Ephraim Atta-Duncan
4d93ed60c5 feat: add deleted account migration 2024-02-05 12:12:02 +00:00
Ephraim Atta-Duncan
d598677dc5 feat: add transparent background to fields 2024-02-02 08:36:54 +00:00
Ephraim Atta-Duncan
014c09bd91 fix: account deletion error for users without two factor authentication 2024-01-28 18:43:20 +00:00
Mythie
5a28eaa4ff feat: add recipient creation 2024-01-22 17:38:02 +11:00
Ephraim Atta-Duncan
9e433af112 feat: require 2fa code before account is deleted 2024-01-21 15:38:32 +00:00
Ephraim Atta-Duncan
7762b1db65 feat: add loading to button 2024-01-21 09:47:50 +00:00
Ephraim Atta-Duncan
a3e560899a feat: delete user from db and unsubscribe from stripe 2024-01-20 23:30:56 +00:00
Ephraim Atta-Duncan
f652ca9b73 feat: account deletion confirmation dialog 2024-01-20 19:07:47 +00:00
Catalin Pit
b6aface982 chore: update api description 2024-01-19 16:59:48 +02:00
Catalin Pit
b28a7f9702 chore: add openapi 2024-01-19 16:55:16 +02:00
Catalin Pit
3b82ba57f3 chore: implemented feedback plus some restructuring 2024-01-17 12:44:25 +02:00
Mythie
a1215df91a refactor: extract api implementation to package
Extracts the API implementation to a package so we can
potentially reuse it across different applications in the
event that we move off using a Next.js API route.

Additionally tidies up the tokens page and form to be more simplified.
2023-12-31 13:58:15 +11:00
Catalin Pit
d283cc2d26 chore: implemented feedback 2023-12-21 16:02:02 +02:00
Catalin Pit
6a56905fea chore: merged main 2023-12-21 10:14:07 +02:00
Catalin Pit
a22ada5f41 chore: add delete cascade 2023-12-20 14:44:43 +02:00
Catalin Pit
fb46b09e4f chore: small changes 2023-12-20 12:47:46 +02:00
Catalin Pit
17486b961d chore: refactor delete dialog 2023-12-19 15:51:43 +02:00
Catalin Pit
da03fc1fd0 chore: finishing touches 2023-12-18 12:24:42 +02:00
Catalin Pit
19736ce60b chore: implemented feedback 2023-12-14 11:05:39 +02:00
Catalin Pit
e79d385534 Merge branch 'main' into feat/public-api 2023-12-11 14:44:29 +02:00
Catalin Pit
8ecd8a7d10 chore: implemented feedback + a small refactoring 2023-12-11 14:33:30 +02:00
Catalin Pit
66c0db91da chore: cleanup and feedback implementation 2023-12-08 13:28:34 +00:00
Catalin Pit
54401b94ae chore: split api contract
moved the schemas from the api contract to a separate file
2023-12-08 09:58:23 +00:00
Catalin Pit
11ae6d3c16 chore: small changes 2023-12-06 16:53:34 +00:00
Catalin Pit
6c5526dd49 chore: update routes
trying to add the route for creating documents
2023-12-06 15:27:30 +00:00
Catalin Pit
936e75fd30 chore: merged main 2023-12-06 13:18:59 +00:00
Catalin Pit
6be4b7ae90 feat: add authorization for api calls 2023-11-30 14:39:31 +02:00
Catalin Pit
76800674ee feat: improve messaging 2023-11-29 14:57:27 +02:00
Catalin Pit
d43d40fd6b feat: improvements to the newly created token message 2023-11-29 14:43:26 +02:00
Catalin Pit
e1732de81d feat: show newly created token 2023-11-28 15:49:46 +02:00
Catalin Pit
6a5fc7a5fb feat: confirm to delete dialog 2023-11-28 12:37:01 +02:00
Catalin Pit
13997d3dca feat: add delete and copy token on token page 2023-11-27 16:29:24 +02:00
Catalin Pit
2deaad5c34 feat: token page 2023-11-27 12:50:21 +02:00
Catalin Pit
fbee6eedc1 feat: api token functions 2023-11-24 16:13:09 +02:00
Catalin Pit
80fe7ccdf5 feat: api token page in the settings 2023-11-24 13:59:33 +02:00
Catalin Pit
2ccede72ea chore: update the contract to add deleteDocument route 2023-11-23 15:23:47 +02:00
Catalin Pit
309b56168a feat: create the model for the api token 2023-11-23 15:21:13 +02:00
Catalin Pit
5c8a77ee8f chore: merged main 2023-11-23 12:05:28 +02:00
Catalin Pit
b3008fb272 feat: add route for retrieving a single document by id 2023-11-23 10:02:22 +02:00
Catalin Pit
6d6c93539f feat: update contract 2023-11-22 15:51:04 +02:00
Catalin Pit
4a6b3edc05 feat: get documents api route with pagination 2023-11-22 15:44:49 +02:00
Catalin Pit
24d9906557 feat: public api start 2023-11-22 15:03:15 +02:00
203 changed files with 10050 additions and 1197 deletions

View File

@@ -10,7 +10,13 @@
"ghcr.io/devcontainers/features/node:1": {}
},
"onCreateCommand": "./.devcontainer/on-create.sh",
"forwardPorts": [3000, 54320, 9000, 2500, 1100],
"forwardPorts": [
3000,
54320,
9000,
2500,
1100
],
"customizations": {
"vscode": {
"extensions": [
@@ -25,8 +31,8 @@
"GitHub.copilot",
"GitHub.vscode-pull-request-github",
"Prisma.prisma",
"VisualStudioExptTeam.vscodeintellicode",
"VisualStudioExptTeam.vscodeintellicode"
]
}
}
}
}

View File

@@ -5,11 +5,10 @@ import { allDocuments } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
export const generateStaticParams = () =>
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
export const dynamic = 'force-dynamic';
export const generateMetadata = ({ params }: { params: { content: string } }) => {
const document = allDocuments.find((post) => post._raw.flattenedPath === params.content);
const document = allDocuments.find((doc) => doc._raw.flattenedPath === params.content);
if (!document) {
return { title: 'Not Found' };

View File

@@ -9,9 +9,6 @@ import { useMDXComponent } from 'next-contentlayer/hooks';
export const dynamic = 'force-dynamic';
export const generateStaticParams = () =>
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
export const generateMetadata = ({ params }: { params: { post: string } }) => {
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);

View File

@@ -5,6 +5,7 @@ import { allBlogPosts } from 'contentlayer/generated';
export const metadata: Metadata = {
title: 'Blog',
};
export default function BlogPage() {
const blogPosts = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.date);

View File

@@ -256,6 +256,7 @@ export const SinglePlayerClient = () => {
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
</Stepper>

View File

@@ -2,6 +2,8 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
@@ -62,6 +64,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<PublicEnvScript />
</head>
<Suspense>

View File

@@ -114,7 +114,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank">
Signup Now
</Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y">

View File

@@ -14,6 +14,7 @@
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
"@documenso/ee": "*",
"@documenso/lib": "*",
@@ -42,6 +43,7 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"remeda": "^1.27.1",
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",

View File

@@ -1,11 +1,11 @@
'use client';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -78,6 +78,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
Subscriptions
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/banner') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/site-settings">
<Settings className="mr-2 h-5 w-5" />
Site Settings
</Link>
</Button>
</div>
);
};

View File

@@ -0,0 +1,200 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export type BannerFormProps = {
banner?: TSiteSettingsBannerSchema;
};
export function BannerForm({ banner }: BannerFormProps) {
const router = useRouter();
const { toast } = useToast();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isLoading: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: 'Banner Updated',
description: 'Your banner 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 update the banner. Please try again later.',
});
}
}
};
return (
<div>
<h2 className="font-semibold">Site Banner</h2>
<p className="text-muted-foreground mt-2 text-sm">
The site banner is a message that is shown at the top of the site. It can be used to display
important information to your users.
</p>
<Form {...form}>
<form
className="mt-4 flex flex-col rounded-md"
onSubmit={form.handleSubmit(onBannerUpdate)}
>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enabled</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset
className="flex flex-col gap-4 md:flex-row"
disabled={!enabled}
aria-disabled={!enabled}
>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>Background Color</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>Text Color</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
The content to show in the banner, HTML is allowed
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
loading={isUpdateSiteSettingLoading}
className="mt-4 justify-end self-end"
>
Update Banner
</Button>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { BannerForm } from './banner-form';
// import { BannerForm } from './banner-form';
export default async function AdminBannerPage() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return (
<div>
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
<div className="mt-8">
<BannerForm banner={banner} />
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import Link from 'next/link';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewButtonProps = {
document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
if (!session) {
return null;
}
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const documentsPath = formatDocumentsPath(document.team?.url);
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query({
id: document.id,
teamId: team?.id,
});
const documentData = documentWithData?.documentData;
if (!documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData, fileName: documentWithData.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
}
};
return match({
isRecipient,
isPending,
isComplete,
isSigned,
})
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-full" asChild>
<Link href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
</Link>
</Button>
))
.with({ isComplete: false }, () => (
<Button className="w-full" asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
</Button>
))
.otherwise(() => null);
};

View File

@@ -0,0 +1,160 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ResendDocumentActionItem } from '../_action-items/resend-document';
import { DeleteDocumentDialog } from '../delete-document-dialog';
import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
export type DocumentPageViewDropdownProps = {
document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) {
return null;
}
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const documentsPath = formatDocumentsPath(team?.url);
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query({
id: document.id,
teamId: team?.id,
});
const documentData = documentWithData?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: document.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
}
};
const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
{(isOwner || isCurrentTeamDocument) && !isComplete && (
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
)}
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<ResendDocumentActionItem
document={document}
recipients={nonSignedRecipients}
team={team}
/>
<DocumentShareButton
documentId={document.id}
token={isOwner ? undefined : recipient?.token}
trigger={({ loading, disabled }) => (
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share Signing Card
</div>
</DropdownMenuItem>
)}
/>
</DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog
id={document.id}
status={document.status}
documentTitle={document.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
)}
{isDuplicateDialogOpen && (
<DuplicateDocumentDialog
id={document.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
team={team}
/>
)}
</DropdownMenu>
);
};

View File

@@ -0,0 +1,72 @@
'use client';
import { useMemo } from 'react';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useLocale } from '@documenso/lib/client-only/providers/locale';
import type { Document, Recipient, User } from '@documenso/prisma/client';
export type DocumentPageViewInformationProps = {
userId: number;
document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
};
export const DocumentPageViewInformation = ({
document,
userId,
}: DocumentPageViewInformationProps) => {
const isMounted = useIsMounted();
const { locale } = useLocale();
const documentInformation = useMemo(() => {
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative();
if (!isMounted) {
createdValue = DateTime.fromJSDate(document.createdAt)
.setLocale(locale)
.toFormat('MMMM d, yyyy');
lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative();
}
return [
{
description: 'Uploaded by',
value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
},
{
description: 'Created',
value: createdValue,
},
{
description: 'Last modified',
value: lastModifiedValue,
},
];
}, [isMounted, document, locale, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">Information</h1>
<ul className="divide-y border-t">
{documentInformation.map((item) => (
<li
key={item.description}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<span className="text-muted-foreground">{item.description}</span>
<span>{item.value}</span>
</li>
))}
</ul>
</section>
);
};

View File

@@ -0,0 +1,153 @@
'use client';
import { useMemo } from 'react';
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
export type DocumentPageViewRecentActivityProps = {
documentId: number;
userId: number;
};
export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
const {
data,
isLoading,
isLoadingError,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
{
documentId,
filterForRecentActivity: true,
orderBy: {
column: 'createdAt',
direction: 'asc',
},
perPage: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">Recent activity</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">Unable to load document history</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
Click here to retry
</button>
</div>
)}
<AnimateGenericFadeInOut>
{data && (
<ul role="list" className="space-y-6 p-4">
{hasNextPage && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="bg-border w-px" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={async () => fetchNextPage()}
className="text-foreground/70 hover:text-muted-foreground text-xs"
>
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
</button>
</li>
)}
{documentAuditLogs.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">No recent activity</p>
</div>
)}
{documentAuditLogs.map((auditLog, auditLogIndex) => (
<li key={auditLog.id} className="relative flex gap-x-4">
<div
className={cn(
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
{match(auditLog.type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />
</div>
))
.otherwise(() => (
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
))}
</div>
<p className="text-muted-foreground dark:text-muted-foreground/70 flex-auto py-0.5 text-xs leading-5">
<span className="text-foreground font-medium">
{formatDocumentAuditLogAction(auditLog, userId).prefix}
</span>{' '}
{formatDocumentAuditLogAction(auditLog, userId).description}
</p>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
</time>
</li>
))}
</ul>
)}
</AnimateGenericFadeInOut>
</section>
);
};

View File

@@ -0,0 +1,115 @@
import Link from 'next/link';
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } from '@documenso/prisma/client';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
export type DocumentPageViewRecipientsProps = {
document: Document & {
Recipient: Recipient[];
};
documentRootPath: string;
};
export const DocumentPageViewRecipients = ({
document,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const recipients = document.Recipient;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">Recipients</h1>
{document.status !== DocumentStatus.COMPLETED && (
<Link
href={`${documentRootPath}/${document.id}/edit?step=signers`}
title="Modify recipients"
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
)}
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
}
/>
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Approved
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
Sent
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Ready
</>
),
)
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
Signed
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
Viewed
</>
))
.exhaustive()}
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
Pending
</Badge>
)}
</li>
))}
</ul>
</section>
);
};

View File

@@ -1,22 +1,34 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { match } from 'ts-pattern';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/formatter/document-status';
import { DocumentPageViewButton } from './document-page-view-button';
import { DocumentPageViewDropdown } from './document-page-view-dropdown';
import { DocumentPageViewInformation } from './document-page-view-information';
import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity';
import { DocumentPageViewRecipients } from './document-page-view-recipients';
export type DocumentPageViewProps = {
params: {
@@ -44,6 +56,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
teamId: team?.id,
}).catch(() => null);
const isDocumentHistoryEnabled = await getServerComponentFlag(
'app_document_page_view_history_sheet',
);
if (!document || !document.documentData) {
redirect(documentRootPath);
}
@@ -67,16 +83,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword;
}
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
const recipients = await getRecipientsForDocument({
documentId,
teamId: team?.id,
userId: user.id,
});
const documentWithRecipients = {
...document,
Recipient: recipients,
};
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
@@ -85,47 +101,105 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="flex flex-row justify-between">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{isDocumentHistoryEnabled && (
<div className="self-end">
<DocumentHistorySheet documentId={document.id} userId={user.id}>
<Button variant="outline">
<Clock9 className="mr-1.5 h-4 w-4" />
Document history
</Button>
</DocumentHistorySheet>
</div>
)}
</div>
{document.status !== InternalDocumentStatus.COMPLETED && (
<EditDocumentForm
className="mt-8"
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
/>
)}
<div className="mt-6 grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
</CardContent>
</Card>
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer
document={document}
key={documentData.id}
documentMeta={documentMeta}
documentData={documentData}
/>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
</h3>
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm ">
{match(document.status)
.with(
DocumentStatus.COMPLETED,
() => 'This document has been signed by all recipients',
)
.with(
DocumentStatus.DRAFT,
() => 'This document is currently a draft and has not been sent',
)
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
);
return `Waiting on ${pendingRecipients.length} recipient${
pendingRecipients.length > 1 ? 's' : ''
}`;
})
.exhaustive()}
</p>
<div className="mt-4 border-t px-4 pt-4">
<DocumentPageViewButton document={documentWithRecipients} team={team} />
</div>
</section>
{/* Document information section. */}
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
{/* Recipients section. */}
<DocumentPageViewRecipients
document={documentWithRecipients}
documentRootPath={documentRootPath}
/>
{/* Recent activity section. */}
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -2,10 +2,16 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import {
type DocumentData,
type DocumentMeta,
DocumentStatus,
type Field,
type Recipient,
type User,
} from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -24,6 +30,8 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type EditDocumentFormProps = {
className?: string;
user: User;
@@ -49,12 +57,10 @@ export const EditDocumentForm = ({
documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
// controlled stepper state
const [step, setStep] = useState<EditDocumentStep>(
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
);
const router = useRouter();
const searchParams = useSearchParams();
const team = useOptionalCurrentTeam();
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
@@ -86,11 +92,30 @@ export const EditDocumentForm = ({
},
};
const [step, setStep] = useState<EditDocumentStep>(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
let initialStep: EditDocumentStep =
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
if (
searchParamStep &&
documentFlow[searchParamStep] !== undefined &&
!(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields'))
) {
initialStep = searchParamStep;
}
return initialStep;
});
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
try {
// Custom invocation server action
await addTitle({
documentId: document.id,
teamId: team?.id,
title: data.title,
});
@@ -113,6 +138,7 @@ export const EditDocumentForm = ({
// Custom invocation server action
await addSigners({
documentId: document.id,
teamId: team?.id,
signers: data.signers,
});
@@ -156,6 +182,7 @@ export const EditDocumentForm = ({
try {
await sendDocument({
documentId: document.id,
teamId: team?.id,
meta: {
subject,
message,

View File

@@ -0,0 +1,122 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
export type DocumentEditPageViewProps = {
params: {
id: string;
};
team?: Team;
};
export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => {
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
redirect(documentRootPath);
}
const { user } = await getRequiredServerComponentSession();
const document = await getDocumentById({
id: documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!document || !document.documentData) {
redirect(documentRootPath);
}
if (document.status === InternalDocumentStatus.COMPLETED) {
redirect(`${documentRootPath}/${documentId}`);
}
const { documentData, documentMeta } = document;
if (documentMeta?.password) {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const securePassword = Buffer.from(
symmetricDecrypt({
key,
data: documentMeta.password,
}),
).toString('utf-8');
documentMeta.password = securePassword;
}
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
<EditDocumentForm
className="mt-8"
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
/>
</div>
);
};

View File

@@ -0,0 +1,11 @@
import { DocumentEditPageView } from './document-edit-page-view';
export type DocumentPageProps = {
params: {
id: string;
};
};
export default function DocumentEditPage({ params }: DocumentPageProps) {
return <DocumentEditPageView params={params} />;
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { LocaleDate } from '~/components/formatter/locale-date';
export type DocumentLogsDataTableProps = {
documentId: number;
};
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
const parser = new UAParser();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.document.findDocumentAuditLogs.useQuery(
{
documentId,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const uppercaseFistLetter = (text: string) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<DataTable
columns={[
{
header: 'Time',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'User',
accessorKey: 'name',
cell: ({ row }) =>
row.original.name || row.original.email ? (
<div>
{row.original.name && (
<p className="truncate" title={row.original.name}>
{row.original.name}
</p>
)}
{row.original.email && (
<p className="truncate" title={row.original.email}>
{row.original.email}
</p>
)}
</div>
) : (
<p>N/A</p>
),
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => (
<span>
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
</span>
),
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading && isInitialLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell className="w-1/2 py-4 pr-4">
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
);
};

View File

@@ -0,0 +1,151 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, DownloadIcon } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Recipient, Team } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card } from '@documenso/ui/primitives/card';
import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status';
import { DocumentLogsDataTable } from './document-logs-data-table';
export type DocumentLogsPageViewProps = {
params: {
id: string;
};
team?: Team;
};
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
redirect(documentRootPath);
}
const { user } = await getRequiredServerComponentSession();
const [document, recipients] = await Promise.all([
getDocumentById({
id: documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null),
getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
]);
if (!document || !document.documentData) {
redirect(documentRootPath);
}
const documentInformation: { description: string; value: string }[] = [
{
description: 'Document title',
value: document.title,
},
{
description: 'Document ID',
value: document.id.toString(),
},
{
description: 'Document status',
value: FRIENDLY_STATUS_MAP[document.status].label,
},
{
description: 'Created by',
value: document.User.name ?? document.User.email,
},
{
description: 'Date created',
value: document.createdAt.toISOString(),
},
{
description: 'Last updated',
value: document.updatedAt.toISOString(),
},
{
description: 'Time zone',
value: document.documentMeta?.timezone ?? 'N/A',
},
];
const formatRecipientText = (recipient: Recipient) => {
let text = recipient.email;
if (recipient.name) {
text = `${recipient.name} (${recipient.email})`;
}
return `${text} - ${recipient.role}`;
};
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link
href={`${documentRootPath}/${document.id}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Document
</Link>
<div className="flex flex-col justify-between sm:flex-row">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<Button variant="outline" className="mr-2 w-full sm:w-auto">
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download certificate
</Button>
<Button className="w-full sm:w-auto">
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download PDF
</Button>
</div>
</div>
<section className="mt-6">
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
{documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{info.description}</h3>
<p className="text-muted-foreground">{info.value}</p>
</div>
))}
<div className="text-foreground text-sm">
<h3 className="font-semibold">Recipients</h3>
<ul className="text-muted-foreground list-inside list-disc">
{recipients.map((recipient) => (
<li key={`recipient-${recipient.id}`}>
<span className="-ml-2">{formatRecipientText(recipient)}</span>
</li>
))}
</ul>
</div>
</Card>
</section>
<section className="mt-6">
<DocumentLogsDataTable documentId={document.id} />
</section>
</div>
);
};

View File

@@ -0,0 +1,11 @@
import { DocumentLogsPageView } from './document-logs-page-view';
export type DocumentsLogsPageProps = {
params: {
id: string;
};
};
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
return <DocumentLogsPageView params={params} />;
}

View File

@@ -118,7 +118,7 @@ export const ResendDocumentActionItem = ({
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<DialogTitle asChild>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>

View File

@@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
<Button className="w-32" asChild>
<Link href={`${documentsPath}/${row.id}`}>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>

View File

@@ -142,7 +142,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
)}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}`}>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
@@ -193,6 +193,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
teamId={team?.id}
/>
)}
{isDuplicateDialogOpen && (

View File

@@ -5,16 +5,19 @@ import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { Document, Recipient, User } from '@documenso/prisma/client';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'url'> | null;
Recipient: Recipient[];
};
teamUrl?: string;
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
@@ -25,14 +28,18 @@ export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const isOwner = row.User.id === session.user.id;
const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
return match({
isOwner,
isRecipient,
isCurrentTeamDocument,
})
.with({ isOwner: true }, () => (
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
<Link
href={`/documents/${row.id}`}
href={`${documentsPath}/${row.id}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>

View File

@@ -66,7 +66,7 @@ export const DocumentsDataTable = ({
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',

View File

@@ -16,12 +16,13 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDraftDocumentDialogProps = {
type DeleteDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
documentTitle: string;
teamId?: number;
};
export const DeleteDocumentDialog = ({
@@ -30,7 +31,8 @@ export const DeleteDocumentDialog = ({
onOpenChange,
status,
documentTitle,
}: DeleteDraftDocumentDialogProps) => {
teamId,
}: DeleteDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
@@ -61,7 +63,7 @@ export const DeleteDocumentDialog = ({
const onDelete = async () => {
try {
await deleteDocument({ id, status });
await deleteDocument({ id, teamId });
} catch {
toast({
title: 'Something went wrong',

View File

@@ -47,7 +47,7 @@ export const DuplicateDocumentDialog = ({
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`${documentsPath}/${newId}`);
router.push(`${documentsPath}/${newId}/edit`);
toast({
title: 'Document Duplicated',

View File

@@ -83,7 +83,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) {
console.error(error);

View File

@@ -9,6 +9,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Banner } from '~/components/(dashboard)/layout/banner';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
@@ -37,6 +38,8 @@ export default async function AuthenticatedDashboardLayout({
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Banner />
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@@ -0,0 +1,124 @@
'use client';
import { signOut } from 'next-auth/react';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteAccountDialogProps = {
className?: string;
user: User;
};
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
const { toast } = useToast();
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
const onDeleteAccount = async () => {
try {
await deleteAccount();
toast({
title: 'Account deleted',
description: 'Your account has been deleted successfully.',
duration: 5000,
});
return await signOut({ callbackUrl: '/' });
} 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:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.',
});
}
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
variant="neutral"
>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertDescription className="mr-2">
Delete your account and all its contents, including completed documents. This action is
irreversible and will cancel your subscription, so proceed with caution.
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
</AlertDescription>
</Alert>
{hasTwoFactorAuthentication && (
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
Disable Two Factor Authentication before deleting your account.
</AlertDescription>
</Alert>
)}
<DialogDescription>
Documenso will delete <span className="font-semibold">all of your documents</span>
, along with all of your completed documents, signatures, and all other resources
belonging to your Account.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={onDeleteAccount}
loading={isDeletingAccount}
variant="destructive"
disabled={hasTwoFactorAuthentication}
>
{isDeletingAccount ? 'Deleting account...' : 'Delete Account'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};

View File

@@ -5,6 +5,8 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { ProfileForm } from '~/components/forms/profile';
import { DeleteAccountDialog } from './delete-account-dialog';
export const metadata: Metadata = {
title: 'Profile',
};
@@ -16,7 +18,9 @@ export default async function ProfileSettingsPage() {
<div>
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<ProfileForm user={user} className="max-w-xl" />
<ProfileForm className="max-w-xl" user={user} />
<DeleteAccountDialog className="mt-8 max-w-xl" user={user} />
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { Button } from '@documenso/ui/primitives/button';
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
import { LocaleDate } from '~/components/formatter/locale-date';
import { ApiTokenForm } from '~/components/forms/token';
export default async function ApiTokensPage() {
const { user } = await getRequiredServerComponentSession();
const tokens = await getUserTokens({ userId: user.id });
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
On this page, you can create new API tokens and manage the existing ones.
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">Your existing tokens</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
Your tokens will be shown here once you create them.
</p>
</div>
)}
{tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Token doesn't have an expiration date
</p>
)}
</div>
<div>
<DeleteTokenDialog token={token}>
<Button variant="destructive">Delete</Button>
</DeleteTokenDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,30 +1,31 @@
'use client';
import { useState, useTransition } from 'react';
import { useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Loader, Plus } from 'lucide-react';
import { AlertTriangle, Loader } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { Recipient, Template } from '@documenso/prisma/client';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { LocaleDate } from '~/components/formatter/locale-date';
import { TemplateType } from '~/components/formatter/template-type';
import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
import { UseTemplateDialog } from './use-template-dialog';
type TemplateWithRecipient = Template & {
Recipient: Recipient[];
};
type TemplatesDataTableProps = {
templates: Template[];
templates: TemplateWithRecipient[];
perPage: number;
page: number;
totalPages: number;
@@ -47,14 +48,6 @@ export const TemplatesDataTable = ({
const { remaining } = useLimits();
const router = useRouter();
const { toast } = useToast();
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
@@ -64,28 +57,6 @@ export const TemplatesDataTable = ({
});
};
const onUseButtonClick = async (templateId: number) => {
try {
const { id } = await createDocumentFromTemplate({
templateId,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
}
};
return (
<div className="relative">
{remaining.documents === 0 && (
@@ -121,22 +92,13 @@ export const TemplatesDataTable = ({
header: 'Actions',
accessorKey: 'actions',
cell: ({ row }) => {
const isRowLoading = loadingStates[row.original.id];
return (
<div className="flex items-center gap-x-4">
<Button
disabled={isRowLoading || remaining.documents === 0}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
await onUseButtonClick(row.original.id);
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
}}
>
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<UseTemplateDialog
templateId={row.original.id}
recipients={row.original.Recipient}
documentRootPath={documentRootPath}
/>
<DataTableActionDropdown
row={row.original}

View File

@@ -0,0 +1,247 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type UseTemplateDialogProps = {
templateId: number;
recipients: Recipient[];
documentRootPath: string;
};
export function UseTemplateDialog({
recipients,
documentRootPath,
templateId,
}: UseTemplateDialogProps) {
const router = useRouter();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
recipients:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}))
: [
{
name: '',
email: '',
role: RecipientRole.SIGNER,
},
],
},
});
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
const { id } = await createDocumentFromTemplate({
templateId,
teamId: team?.id,
recipients: data.recipients,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
}
};
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({
control,
name: 'recipients',
});
return (
<Dialog>
<DialogTrigger asChild>
<Button className="cursor-pointer">
<Plus className="-ml-1 mr-2 h-4 w-4" />
Use Template
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Document Recipients</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
</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
control={control}
name={`recipients.${index}.email`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
<Controller
control={control}
name={`recipients.${index}.name`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
/>
)}
/>
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`recipients.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
</div>
</div>
))}
</div>
<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>
</Dialog>
);
}

View File

@@ -29,6 +29,7 @@ import { NameField } from './name-field';
import { NoLongerAvailable } from './no-longer-available';
import { SigningProvider } from './provider';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
export type SigningPageProps = {
params: {
@@ -168,6 +169,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>

View File

@@ -0,0 +1,166 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningFieldContainer } from './signing-field-container';
export type TextFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
export const TextField = ({ field, recipient }: TextFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState('');
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
useEffect(() => {
if (!showCustomTextModal && !isLocalSignatureSet) {
setLocalCustomText('');
}
}, [showCustomTextModal, isLocalSignatureSet]);
const onSign = async () => {
try {
if (!localText) {
setIsLocalSignatureSet(false);
setShowCustomTextModal(true);
return;
}
if (!localText) {
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: localText,
isBase64: true,
});
setLocalCustomText('');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the text.',
variant: 'destructive',
});
}
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Text</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
<DialogContent>
<DialogTitle>
Enter your Text <span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="">
<Label htmlFor="custom-text">Custom Text</Label>
<Input
id="custom-text"
className="border-border mt-2 w-full rounded-md border"
onChange={(e) => setLocalCustomText(e.target.value)}
/>
</div>
<DialogFooter>
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowCustomTextModal(false);
setLocalCustomText('');
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localText}
onClick={() => {
setShowCustomTextModal(false);
setIsLocalSignatureSet(true);
void onSign();
}}
>
Save Text
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@@ -0,0 +1,21 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentEditPageView } from '~/app/(dashboard)/documents/[id]/edit/document-edit-page-view';
export type DocumentPageProps = {
params: {
id: string;
teamUrl: string;
};
};
export default async function TeamsDocumentEditPage({ params }: DocumentPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentEditPageView params={params} team={team} />;
}

View File

@@ -0,0 +1,20 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentLogsPageView } from '~/app/(dashboard)/documents/[id]/logs/document-logs-page-view';
export type TeamDocumentsLogsPageProps = {
params: {
id: string;
teamUrl: string;
};
};
export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentLogsPageView params={params} team={team} />;
}

View File

@@ -11,6 +11,7 @@ import { SubscriptionStatus } from '@documenso/prisma/client';
import { Header } from '~/components/(dashboard)/layout/header';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
import { TeamProvider } from '~/providers/team';
import { LayoutBillingBanner } from './layout-billing-banner';
@@ -56,7 +57,9 @@ export default async function AuthenticatedTeamsLayout({
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<TeamProvider team={team}>
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
</TeamProvider>
<RefreshOnFocus />
</LimitsProvider>

View File

@@ -0,0 +1,85 @@
import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
import { LocaleDate } from '~/components/formatter/locale-date';
import { ApiTokenForm } from '~/components/forms/token';
type ApiTokensPageProps = {
params: {
teamUrl: string;
};
};
export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
On this page, you can create new API tokens and manage the existing ones.
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" teamId={team.id} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">Your existing tokens</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
Your tokens will be shown here once you create them.
</p>
</div>
)}
{tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Token doesn't have an expiration date
</p>
)}
</div>
<div>
<DeleteTokenDialog token={token} teamId={team.id}>
<Button variant="destructive">Delete</Button>
</DeleteTokenDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
'use client';
export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';

View File

@@ -0,0 +1,29 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
export const Banner = async () => {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return (
<>
{banner && banner.enabled && (
<div className="mb-2" style={{ background: banner.data.bgColor }}>
<div
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
style={{ color: banner.data.textColor }}
>
<div className="flex items-center">
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
</div>
</div>
</div>
)}
</>
);
};
// Banner
// Custom Text
// Custom Text with Custom Icon

View File

@@ -166,22 +166,24 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
</div>
</DropdownMenuLabel>
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<Link href={formatRedirectUrlOnSwitch(team.url)}>
<AvatarWithText
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
secondaryText={formatSecondaryAvatarText(team)}
rightSideComponent={
isPathTeamUrl(team.url) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</Link>
</DropdownMenuItem>
))}
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<Link href={formatRedirectUrlOnSwitch(team.url)}>
<AvatarWithText
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
secondaryText={formatSecondaryAvatarText(team)}
rightSideComponent={
isPathTeamUrl(team.url) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</Link>
</DropdownMenuItem>
))}
</div>
</>
) : (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>

View File

@@ -3,6 +3,7 @@
import Link from 'next/link';
import {
Braces,
CreditCard,
FileSpreadsheet,
Lock,
@@ -98,6 +99,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings/tokens" className="cursor-pointer">
<Braces className="mr-2 h-4 w-4" />
API Tokens
</Link>
</DropdownMenuItem>
{isBillingEnabled && (
<DropdownMenuItem asChild>
<Link href="/settings/billing" className="cursor-pointer">

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
export const EXPIRATION_DATES = {
ONE_WEEK: '7 days',
ONE_MONTH: '1 month',
THREE_MONTHS: '3 months',
SIX_MONTHS: '6 months',
ONE_YEAR: '12 months',
} as const;

View File

@@ -0,0 +1,185 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { ApiToken } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTokenDialogProps = {
teamId?: number;
token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void;
children?: React.ReactNode;
};
export default function DeleteTokenDialog({
teamId,
token,
onDelete,
children,
}: DeleteTokenDialogProps) {
const router = useRouter();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const deleteMessage = `delete ${token.name}`;
const ZDeleteTokenDialogSchema = z.object({
tokenName: z.literal(deleteMessage, {
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
}),
});
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
onSuccess() {
onDelete?.();
},
});
const form = useForm<TDeleteTokenByIdMutationSchema>({
resolver: zodResolver(ZDeleteTokenDialogSchema),
values: {
tokenName: '',
},
});
const onSubmit = async () => {
try {
await deleteTokenMutation({
id: token.id,
teamId,
});
toast({
title: 'Token deleted',
description: 'The token was deleted successfully.',
duration: 5000,
});
setIsOpen(false);
router.refresh();
} catch (error) {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
duration: 5000,
description:
'We encountered an unknown error while attempting to delete this token. Please try again later.',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<DialogTrigger asChild={true}>
{children ?? (
<Button className="mr-4" variant="destructive">
Delete
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure you want to delete this token?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your token will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</FormLabel>
<FormControl>
<Input className="bg-background" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
I'm sure! Delete it
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { CreditCard, Settings, Users } from 'lucide-react';
import { Braces, CreditCard, Settings, Users } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
@@ -21,6 +21,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
@@ -48,6 +49,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
<Link href={tokensPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
>
<Braces className="mr-2 h-5 w-5" />
API Tokens
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { Braces, CreditCard, Key, User } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
@@ -21,6 +21,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
@@ -56,6 +57,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
<Link href={tokensPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
>
<Braces className="mr-2 h-5 w-5" />
API Tokens
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button

View File

@@ -0,0 +1,28 @@
'use client';
import React from 'react';
import { Badge } from '@documenso/ui/primitives/badge';
export type DocumentHistorySheetChangesProps = {
values: {
key: string | React.ReactNode;
value: string | React.ReactNode;
}[];
};
export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => {
return (
<Badge
className="text-muted-foreground mt-3 block w-full space-y-0.5 text-xs"
variant="neutral"
>
{values.map(({ key, value }, i) => (
<p key={typeof key === 'string' ? key : i}>
<span>{key}: </span>
<span className="font-normal">{value}</span>
</p>
))}
</Badge>
);
};

View File

@@ -0,0 +1,318 @@
'use client';
import { useMemo, useState } from 'react';
import { ArrowRightIcon, Loader } from 'lucide-react';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { LocaleDate } from '~/components/formatter/locale-date';
import { DocumentHistorySheetChanges } from './document-history-sheet-changes';
export type DocumentHistorySheetProps = {
documentId: number;
userId: number;
isMenuOpen?: boolean;
onMenuOpenChange?: (_value: boolean) => void;
children?: React.ReactNode;
};
export const DocumentHistorySheet = ({
documentId,
userId,
isMenuOpen,
onMenuOpenChange,
children,
}: DocumentHistorySheetProps) => {
const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false);
const {
data,
isLoading,
isLoadingError,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
{
documentId,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
const extractBrowser = (userAgent?: string | null) => {
if (!userAgent) {
return 'Unknown';
}
const parser = new UAParser(userAgent);
parser.setUA(userAgent);
const result = parser.getResult();
return result.browser.name;
};
/**
* Applies the following formatting for a given text:
* - Uppercase first lower, lowercase rest
* - Replace _ with spaces
*
* @param text The text to format
* @returns The formatted text
*/
const formatGenericText = (text: string) => {
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
};
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
{children && <SheetTrigger asChild>{children}</SheetTrigger>}
<SheetContent
sheetClass="backdrop-blur-none"
className="flex w-full max-w-[500px] flex-col overflow-y-auto p-0"
>
<div className="text-foreground px-6 pt-6">
<h1 className="text-lg font-medium">Document history</h1>
<button
className="text-muted-foreground text-sm"
onClick={() => setIsUserDetailsVisible(!isUserDetailsVisible)}
>
{isUserDetailsVisible ? 'Hide' : 'Show'} additional information
</button>
</div>
{isLoading && (
<div className="flex h-full items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center">
<p className="text-foreground/80 text-sm">Unable to load document history</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
Click here to retry
</button>
</div>
)}
{data && (
<ul
className={cn('divide-y border-t', {
'mb-4 border-b': !hasNextPage,
})}
>
{documentAuditLogs.map((auditLog) => (
<li className="px-4 py-2.5" key={auditLog.id}>
<div className="flex flex-row items-center">
<Avatar className="mr-2 h-9 w-9">
<AvatarFallback className="text-xs text-gray-400">
{(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="text-foreground text-xs font-bold">
{formatDocumentAuditLogActionString(auditLog, userId)}
</p>
<p className="text-foreground/50 text-xs">
<LocaleDate date={auditLog.createdAt} format="d MMM, yyyy HH:MM a" />
</p>
</div>
</div>
{match(auditLog)
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
() => null,
)
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED },
({ data }) => {
const values = [
{
key: 'Email',
value: data.recipientEmail,
},
{
key: 'Role',
value: formatGenericText(data.recipientRole),
},
];
// Insert the name to the start of the array if available.
if (data.recipientName) {
values.unshift({
key: 'Name',
value: data.recipientName,
});
}
return <DocumentHistorySheetChanges values={values} />;
},
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => {
if (data.changes.length === 0) {
return null;
}
return (
<DocumentHistorySheetChanges
values={data.changes.map(({ type, from, to }) => ({
key: formatGenericText(type),
value: (
<span className="inline-flex flex-row items-center">
<span>{type === 'ROLE' ? formatGenericText(from) : from}</span>
<ArrowRightIcon className="h-4 w-4" />
<span>{type === 'ROLE' ? formatGenericText(to) : to}</span>
</span>
),
}))}
/>
);
})
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field',
value: formatGenericText(data.fieldType),
},
{
key: 'Recipient',
value: formatGenericText(data.fieldRecipientEmail),
},
]}
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
if (data.changes.length === 0) {
return null;
}
return (
<DocumentHistorySheetChanges
values={data.changes.map((change) => ({
key: formatGenericText(change.type),
value: change.type === 'PASSWORD' ? '*********' : change.to,
}))}
/>
);
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field inserted',
value: formatGenericText(data.field.type),
},
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field uninserted',
value: formatGenericText(data.field),
},
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Type',
value: DOCUMENT_AUDIT_LOG_EMAIL_FORMAT[data.emailType].description,
},
{
key: 'Sent to',
value: data.recipientEmail,
},
]}
/>
))
.exhaustive()}
{isUserDetailsVisible && (
<>
<div className="mb-1 mt-2 flex flex-row space-x-2">
<Badge variant="neutral" className="text-muted-foreground">
IP: {auditLog.ipAddress ?? 'Unknown'}
</Badge>
<Badge variant="neutral" className="text-muted-foreground">
Browser: {extractBrowser(auditLog.userAgent)}
</Badge>
</div>
</>
)}
</li>
))}
{hasNextPage && (
<div className="flex items-center justify-center py-4">
<Button
variant="outline"
loading={isFetchingNextPage}
onClick={async () => fetchNextPage()}
>
Show more
</Button>
</div>
)}
</ul>
)}
</SheetContent>
</Sheet>
);
};

View File

@@ -13,7 +13,7 @@ type FriendlyStatus = {
color: string;
};
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
PENDING: {
label: 'Pending',
icon: Clock,

View File

@@ -1,7 +1,7 @@
'use client';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
@@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale';
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
date: string | number | Date;
format?: DateTimeFormatOptions;
format?: DateTimeFormatOptions | string;
};
/**
@@ -22,13 +22,24 @@ export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
const { locale } = useLocale();
const formatDateTime = useCallback(
(date: DateTime) => {
if (typeof format === 'string') {
return date.toFormat(format);
}
return date.toLocaleString(format);
},
[format],
);
const [localeDate, setLocaleDate] = useState(() =>
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)),
);
useEffect(() => {
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
}, [date, format]);
setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date))));
}, [date, format, formatDateTime]);
return (
<span className={className} {...props}>

View File

@@ -1,14 +1,12 @@
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { renderSVG } from 'uqr';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -54,14 +52,16 @@ export const EnableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: EnableAuthenticatorAppDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
trpc.twoFactorAuthentication.setup.useMutation();
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
trpc.twoFactorAuthentication.enable.useMutation();
const {
mutateAsync: enableTwoFactorAuthentication,
data: enableTwoFactorAuthenticationData,
isLoading: isEnableTwoFactorAuthenticationDataLoading,
} = trpc.twoFactorAuthentication.enable.useMutation();
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
defaultValues: {
@@ -115,6 +115,19 @@ export const EnableAuthenticatorAppDialog = ({
}
};
const downloadRecoveryCodes = () => {
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
type: 'text/plain',
});
downloadFile({
filename: 'documenso-2FA-recovery-codes.txt',
data: blob,
});
}
};
const onEnableTwoFactorAuthenticationFormSubmit = async ({
token,
}: TEnableTwoFactorAuthenticationForm) => {
@@ -136,14 +149,6 @@ export const EnableAuthenticatorAppDialog = ({
}
};
const onCompleteClick = () => {
flushSync(() => {
onOpenChange(false);
});
router.refresh();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
@@ -270,9 +275,16 @@ export const EnableAuthenticatorAppDialog = ({
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
)}
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
<Button type="button" onClick={() => onCompleteClick()}>
Complete
<div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
onClick={downloadRecoveryCodes}
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
loading={isEnableTwoFactorAuthenticationDataLoading}
>
Download
</Button>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -42,8 +43,11 @@ export type ViewRecoveryCodesDialogProps = {
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
const { toast } = useToast();
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
const {
mutateAsync: viewRecoveryCodes,
data: viewRecoveryCodesData,
isLoading: isViewRecoveryCodesDataLoading,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: {
@@ -62,6 +66,19 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
return 'view';
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
const downloadRecoveryCodes = () => {
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
type: 'text/plain',
});
downloadFile({
filename: 'documenso-2FA-recovery-codes.txt',
data: blob,
});
}
};
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
try {
await viewRecoveryCodes({ password });
@@ -139,8 +156,17 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)}
<div className="mt-4 flex flex-row-reverse items-center justify-between">
<div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
disabled={!viewRecoveryCodesData?.recoveryCodes}
loading={isViewRecoveryCodesDataLoading}
onClick={downloadRecoveryCodes}
>
Download
</Button>
</div>
</div>
))

View File

@@ -29,6 +29,11 @@ export const ZProfileFormSchema = z.object({
signature: z.string().min(1, 'Signature Pad cannot be empty'),
});
export const ZTwoFactorAuthTokenSchema = z.object({
token: z.string(),
});
export type TTwoFactorAuthTokenSchema = z.infer<typeof ZTwoFactorAuthTokenSchema>;
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
export type ProfileFormProps = {
@@ -50,8 +55,11 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
});
const isSubmitting = form.formState.isSubmitting;
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
try {
@@ -133,7 +141,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
/>
</fieldset>
<Button type="submit" loading={isSubmitting}>
<Button type="submit" loading={isSubmitting} className="self-end">
{isSubmitting ? 'Updating profile...' : 'Update profile'}
</Button>
</form>

View File

@@ -0,0 +1,257 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants';
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({
enabled: z.boolean(),
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
export type ApiTokenFormProps = {
className?: string;
teamId?: number;
};
export const ApiTokenForm = ({ className, teamId }: ApiTokenFormProps) => {
const router = useRouter();
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const [newlyCreatedToken, setNewlyCreatedToken] = useState('');
const [noExpirationDate, setNoExpirationDate] = useState(false);
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data.token);
},
});
const form = useForm<TCreateTokenFormSchema>({
resolver: zodResolver(ZCreateTokenFormSchema),
defaultValues: {
tokenName: '',
expirationDate: '',
enabled: false,
},
});
const copyToken = async (token: string) => {
try {
const copied = await copy(token);
if (!copied) {
throw new Error('Unable to copy the token');
}
toast({
title: 'Token copied to clipboard',
description: 'The token was copied to your clipboard.',
});
} catch (error) {
toast({
title: 'Unable to copy token',
description: 'We were unable to copy the token to your clipboard. Please try again.',
variant: 'destructive',
});
}
};
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
try {
await createTokenMutation({
teamId,
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});
toast({
title: 'Token created',
description: 'A new token was created successfully.',
duration: 5000,
});
form.reset();
router.refresh();
} catch (error) {
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: error.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
duration: 5000,
description:
'We encountered an unknown error while attempting create the new token. Please try again later.',
});
}
}
};
return (
<div className={cn(className)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-4">
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">Token name</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Input type="text" {...field} />
</FormControl>
</div>
<FormDescription className="text-xs italic">
Please enter a meaningful name for your token. This will help you identify it
later.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">Token expiration date</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose..." />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{date}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="">
<FormLabel className="text-muted-foreground mt-2">Never expire</FormLabel>
<FormControl>
<div className="block md:py-1.5">
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={(val) => {
setNoExpirationDate((prev) => !prev);
field.onChange(val);
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="hidden md:inline-flex"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
Create token
</Button>
<div className="md:hidden">
<Button
type="submit"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
Create token
</Button>
</div>
</fieldset>
</form>
</Form>
{newlyCreatedToken && (
<Card className="mt-8" gradient>
<CardContent className="p-4">
<p className="text-muted-foreground mt-2 text-sm">
Your token was created successfully! Make sure to copy it because you won't be able to
see it again!
</p>
<p className="bg-muted-foreground/10 my-4 rounded-md px-2.5 py-1 font-mono text-sm">
{newlyCreatedToken}
</p>
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken)}>
Copy token
</Button>
</CardContent>
</Card>
)}
</div>
);
};

View File

@@ -0,0 +1,17 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createNextRouter } from '@documenso/api/next';
import { ApiContractV1 } from '@documenso/api/v1/contract';
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
const nextRouteHandler = createNextRouter(ApiContractV1, ApiContractV1Implementation, {
responseValidation: true,
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// TODO: Dirty hack to make ts-rest handler work with next.js in a more intuitive way.
req.query['ts-rest'] = Array.isArray(req.query['ts-rest']) ? req.query['ts-rest'] : []; // Make `ts-rest` an array.
req.query['ts-rest'].unshift('api', 'v1'); // Prepend our base path to the array.
return await nextRouteHandler(req, res);
}

View File

@@ -0,0 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json(OpenAPIV1);
}

View File

@@ -0,0 +1,31 @@
'use client';
import { createContext, useContext } from 'react';
import React from 'react';
import type { Team } from '@documenso/prisma/client';
interface TeamProviderProps {
children: React.ReactNode;
team: Team;
}
const TeamContext = createContext<Team | null>(null);
export const useCurrentTeam = (): Team | null => {
const context = useContext(TeamContext);
if (!context) {
throw new Error('useCurrentTeam must be used within a TeamProvider');
}
return context;
};
export const useOptionalCurrentTeam = (): Team | null => {
return useContext(TeamContext);
};
export const TeamProvider = ({ children, team }: TeamProviderProps) => {
return <TeamContext.Provider value={team}>{children}</TeamContext.Provider>;
};

1981
package-lock.json generated

File diff suppressed because it is too large Load Diff

1
packages/api/index.ts Normal file
View File

@@ -0,0 +1 @@
export {};

1
packages/api/next.ts Normal file
View File

@@ -0,0 +1 @@
export { createNextRouter } from '@ts-rest/next';

30
packages/api/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@documenso/api",
"version": "1.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf node_modules"
},
"files": [
"index.ts",
"next.ts",
"v1/"
],
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5",
"@ts-rest/next": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@types/swagger-ui-react": "^4.18.3",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"swagger-ui-react": "^5.11.0",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"strict": true,
}
}

View File

@@ -0,0 +1,10 @@
'use client';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
};

191
packages/api/v1/contract.ts Normal file
View File

@@ -0,0 +1,191 @@
import { initContract } from '@ts-rest/core';
import {
ZAuthorizationHeadersSchema,
ZCreateDocumentFromTemplateMutationResponseSchema,
ZCreateDocumentFromTemplateMutationSchema,
ZCreateDocumentMutationResponseSchema,
ZCreateDocumentMutationSchema,
ZCreateFieldMutationSchema,
ZCreateRecipientMutationSchema,
ZDeleteDocumentMutationSchema,
ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema,
ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema,
ZSuccessfulFieldResponseSchema,
ZSuccessfulGetDocumentResponseSchema,
ZSuccessfulRecipientResponseSchema,
ZSuccessfulResponseSchema,
ZSuccessfulSigningResponseSchema,
ZUnsuccessfulResponseSchema,
ZUpdateFieldMutationSchema,
ZUpdateRecipientMutationSchema,
} from './schema';
const c = initContract();
export const ApiContractV1 = c.router(
{
getDocuments: {
method: 'GET',
path: '/api/v1/documents',
query: ZGetDocumentsQuerySchema,
responses: {
200: ZSuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get all documents',
},
getDocument: {
method: 'GET',
path: '/api/v1/documents/:id',
responses: {
200: ZSuccessfulGetDocumentResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get a single document',
},
createDocument: {
method: 'POST',
path: '/api/v1/documents',
body: ZCreateDocumentMutationSchema,
responses: {
200: ZCreateDocumentMutationResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Upload a new document and get a presigned URL',
},
createDocumentFromTemplate: {
method: 'POST',
path: '/api/v1/templates/:templateId/create-document',
body: ZCreateDocumentFromTemplateMutationSchema,
responses: {
200: ZCreateDocumentFromTemplateMutationResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new document from an existing template',
},
sendDocument: {
method: 'POST',
path: '/api/v1/documents/:id/send',
body: ZSendDocumentForSigningMutationSchema,
responses: {
200: ZSuccessfulSigningResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Send a document for signing',
},
deleteDocument: {
method: 'DELETE',
path: '/api/v1/documents/:id',
body: ZDeleteDocumentMutationSchema,
responses: {
200: ZSuccessfulDocumentResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a document',
},
createRecipient: {
method: 'POST',
path: '/api/v1/documents/:id/recipients',
body: ZCreateRecipientMutationSchema,
responses: {
200: ZSuccessfulRecipientResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a recipient for a document',
},
updateRecipient: {
method: 'PATCH',
path: '/api/v1/documents/:id/recipients/:recipientId',
body: ZUpdateRecipientMutationSchema,
responses: {
200: ZSuccessfulRecipientResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Update a recipient for a document',
},
deleteRecipient: {
method: 'DELETE',
path: '/api/v1/documents/:id/recipients/:recipientId',
body: ZDeleteRecipientMutationSchema,
responses: {
200: ZSuccessfulRecipientResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a recipient from a document',
},
createField: {
method: 'POST',
path: '/api/v1/documents/:id/fields',
body: ZCreateFieldMutationSchema,
responses: {
200: ZSuccessfulFieldResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a field for a document',
},
updateField: {
method: 'PATCH',
path: '/api/v1/documents/:id/fields/:fieldId',
body: ZUpdateFieldMutationSchema,
responses: {
200: ZSuccessfulFieldResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Update a field for a document',
},
deleteField: {
method: 'DELETE',
path: '/api/v1/documents/:id/fields/:fieldId',
body: ZDeleteFieldMutationSchema,
responses: {
200: ZSuccessfulFieldResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a field from a document',
},
},
{
baseHeaders: ZAuthorizationHeadersSchema,
},
);

View File

@@ -0,0 +1,59 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const { status, body } = await client.createDocument({
body: {
title: 'My Document',
recipients: [
{
name: 'John Doe',
email: 'john@example.com',
role: 'SIGNER',
},
{
name: 'Jane Doe',
email: 'jane@example.com',
role: 'APPROVER',
},
],
meta: {
subject: 'Please sign this document',
message: 'Hey {signer.name}, please sign the following document: {document.name}',
},
},
});
if (status !== 200) {
throw new Error('Failed to create document');
}
const { uploadUrl, documentId } = body;
await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
},
body: '<raw-binary-data>',
});
await client.sendDocument({
params: {
id: documentId.toString(),
},
});
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,43 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const recipientId = 1;
const { status, body } = await client.createField({
params: {
id: documentId,
},
body: {
type: 'SIGNATURE',
pageHeight: 2.5, // percent of page to occupy in height
pageWidth: 5, // percent of page to occupy in width
pageX: 10, // percent from left
pageY: 10, // percent from top
pageNumber: 1,
recipientId,
},
});
if (status !== 200) {
throw new Error('Failed to create field');
}
const { id: fieldId } = body;
console.log(`Field created with id: ${fieldId}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,39 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const fieldId = '1';
const { status } = await client.updateField({
params: {
id: documentId,
fieldId,
},
body: {
type: 'SIGNATURE',
pageHeight: 2.5, // percent of page to occupy in height
pageWidth: 5, // percent of page to occupy in width
pageX: 10, // percent from left
pageY: 10, // percent from top
pageNumber: 1,
},
});
if (status !== 200) {
throw new Error('Failed to update field');
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const fieldId = '1';
const { status } = await client.deleteField({
params: {
id: documentId,
fieldId,
},
});
if (status !== 200) {
throw new Error('Failed to remove field');
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,38 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const { status, body } = await client.createRecipient({
params: {
id: documentId,
},
body: {
name: 'John Doe',
email: 'john@example.com',
role: 'APPROVER',
},
});
if (status !== 200) {
throw new Error('Failed to add recipient');
}
const { id: recipientId } = body;
console.log(`Recipient added with id: ${recipientId}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,34 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const recipientId = '1';
const { status } = await client.updateRecipient({
params: {
id: documentId,
recipientId,
},
body: {
name: 'Johnathon Doe',
},
});
if (status !== 200) {
throw new Error('Failed to update recipient');
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const recipientId = '1';
const { status } = await client.deleteRecipient({
params: {
id: documentId,
recipientId,
},
});
if (status !== 200) {
throw new Error('Failed to update recipient');
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const { status, body } = await client.getDocument({
params: {
id: documentId,
},
});
if (status !== 200) {
throw new Error('Failed to get document');
}
console.log(`Got document with id: ${documentId} and title: ${body.title}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,37 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const page = 1;
const perPage = 10;
const { status, body } = await client.getDocuments({
query: {
page,
perPage,
},
});
if (status !== 200) {
throw new Error('Failed to get documents');
}
for (const document of body.documents) {
console.log(`Got document with id: ${document.id} and title: ${document.title}`);
}
console.log(`Total documents: ${body.totalPages * perPage}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,800 @@
import { createNextRoute } from '@ts-rest/next';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { createField } from '@documenso/lib/server-only/field/create-field';
import { deleteField } from '@documenso/lib/server-only/field/delete-field';
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
import { updateField } from '@documenso/lib/server-only/field/update-field';
import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient';
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
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 { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
const { data: documents, totalPages } = await findDocuments({
page,
perPage,
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: {
documents,
totalPages,
},
};
}),
getDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
try {
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
const recipients = await getRecipientsForDocument({
documentId: Number(documentId),
teamId: team?.id,
userId: user.id,
});
return {
status: 200,
body: {
...document,
recipients,
},
};
} catch (err) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
}),
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
try {
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
const deletedDocument = await deleteDocument({
id: document.id,
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: deletedDocument,
};
} catch (err) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
}),
createDocument: authenticatedMiddleware(async (args, user, team) => {
const { body } = args;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
return {
status: 500,
body: {
message: 'Create document is not available without S3 transport.',
},
};
}
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
};
}
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
const documentData = await createDocumentData({
data: key,
type: DocumentDataType.S3_PATH,
});
const document = await createDocument({
title: body.title,
userId: user.id,
teamId: team?.id,
documentDataId: documentData.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({
userId: user.id,
teamId: team?.id,
documentId: document.id,
recipients: body.recipients,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
return {
status: 200,
body: {
uploadUrl: url,
documentId: document.id,
recipients: recipients.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
})),
},
};
} catch (err) {
return {
status: 404,
body: {
message: 'An error has occured while uploading the file',
},
};
}
}),
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
};
}
const templateId = Number(params.templateId);
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplate({
templateId,
userId: user.id,
teamId: team?.id,
recipients: body.recipients,
});
await updateDocument({
documentId: document.id,
userId: user.id,
teamId: team?.id,
data: {
title: fileName,
},
});
if (body.meta) {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}
return {
status: 200,
body: {
documentId: document.id,
recipients: document.Recipient.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
})),
},
};
}),
sendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id } = args.params;
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already complete',
},
};
}
try {
// await setRecipientsForDocument({
// userId: user.id,
// documentId: Number(id),
// recipients: [
// {
// email: body.signerEmail,
// name: body.signerName ?? '',
// },
// ],
// });
// await setFieldsForDocument({
// documentId: Number(id),
// userId: user.id,
// fields: body.fields.map((field) => ({
// signerEmail: body.signerEmail,
// type: field.fieldType,
// pageNumber: field.pageNumber,
// pageX: field.pageX,
// pageY: field.pageY,
// pageWidth: field.pageWidth,
// pageHeight: field.pageHeight,
// })),
// });
// if (body.emailBody || body.emailSubject) {
// await upsertDocumentMeta({
// documentId: Number(id),
// subject: body.emailSubject ?? '',
// message: body.emailBody ?? '',
// });
// }
await sendDocument({
documentId: Number(id),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
return {
status: 200,
body: {
message: 'Document sent for signing successfully',
},
};
} catch (err) {
return {
status: 500,
body: {
message: 'An error has occured while sending the document for signing',
},
};
}
}),
createRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
const { name, email, role } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const recipients = await getRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
teamId: team?.id,
});
const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email);
if (recipientAlreadyExists) {
return {
status: 400,
body: {
message: 'Recipient already exists',
},
};
}
try {
const newRecipients = await setRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
teamId: team?.id,
recipients: [
...recipients,
{
email,
name,
role,
},
],
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const newRecipient = newRecipients.find((recipient) => recipient.email === email);
if (!newRecipient) {
throw new Error('Recipient not found');
}
return {
status: 200,
body: {
...newRecipient,
documentId: Number(documentId),
},
};
} catch (err) {
return {
status: 500,
body: {
message: 'An error has occured while creating the recipient',
},
};
}
}),
updateRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, recipientId } = args.params;
const { name, email, role } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const updatedRecipient = await updateRecipient({
documentId: Number(documentId),
recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
email,
name,
role,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null);
if (!updatedRecipient) {
return {
status: 404,
body: {
message: 'Recipient not found',
},
};
}
return {
status: 200,
body: {
...updatedRecipient,
documentId: Number(documentId),
},
};
}),
deleteRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, recipientId } = args.params;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const deletedRecipient = await deleteRecipient({
documentId: Number(documentId),
recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null);
if (!deletedRecipient) {
return {
status: 400,
body: {
message: 'Unable to delete recipient',
},
};
}
return {
status: 200,
body: {
...deletedRecipient,
documentId: Number(documentId),
},
};
}),
createField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const recipient = await getRecipientById({
id: Number(recipientId),
documentId: Number(documentId),
}).catch(() => null);
if (!recipient) {
return {
status: 404,
body: {
message: 'Recipient not found',
},
};
}
if (recipient.signingStatus === SigningStatus.SIGNED) {
return {
status: 400,
body: {
message: 'Recipient has already signed the document',
},
};
}
const field = await createField({
documentId: Number(documentId),
recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const remappedField = {
id: field.id,
documentId: field.documentId,
recipientId: field.recipientId ?? -1,
type: field.type,
pageNumber: field.page,
pageX: Number(field.positionX),
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
customText: field.customText,
inserted: field.inserted,
};
return {
status: 200,
body: {
...remappedField,
documentId: Number(documentId),
},
};
}),
updateField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, fieldId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const recipient = await getRecipientById({
id: Number(recipientId),
documentId: Number(documentId),
}).catch(() => null);
if (!recipient) {
return {
status: 404,
body: {
message: 'Recipient not found',
},
};
}
if (recipient.signingStatus === SigningStatus.SIGNED) {
return {
status: 400,
body: {
message: 'Recipient has already signed the document',
},
};
}
const updatedField = await updateField({
fieldId: Number(fieldId),
userId: user.id,
teamId: team?.id,
documentId: Number(documentId),
recipientId: recipientId ? Number(recipientId) : undefined,
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const remappedField = {
id: updatedField.id,
documentId: updatedField.documentId,
recipientId: updatedField.recipientId ?? -1,
type: updatedField.type,
pageNumber: updatedField.page,
pageX: Number(updatedField.positionX),
pageY: Number(updatedField.positionY),
pageWidth: Number(updatedField.width),
pageHeight: Number(updatedField.height),
customText: updatedField.customText,
inserted: updatedField.inserted,
};
return {
status: 200,
body: {
...remappedField,
documentId: Number(documentId),
},
};
}),
deleteField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, fieldId } = args.params;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const field = await getFieldById({
fieldId: Number(fieldId),
documentId: Number(documentId),
}).catch(() => null);
if (!field) {
return {
status: 404,
body: {
message: 'Field not found',
},
};
}
const recipient = await getRecipientById({
id: Number(field.recipientId),
documentId: Number(documentId),
}).catch(() => null);
if (recipient?.signingStatus === SigningStatus.SIGNED) {
return {
status: 400,
body: {
message: 'Recipient has already signed the document',
},
};
}
const deletedField = await deleteField({
documentId: Number(documentId),
fieldId: Number(fieldId),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null);
if (!deletedField) {
return {
status: 400,
body: {
message: 'Unable to delete field',
},
};
}
const remappedField = {
id: deletedField.id,
documentId: deletedField.documentId,
recipientId: deletedField.recipientId ?? -1,
type: deletedField.type,
pageNumber: deletedField.page,
pageX: Number(deletedField.positionX),
pageY: Number(deletedField.positionY),
pageWidth: Number(deletedField.width),
pageHeight: Number(deletedField.height),
customText: deletedField.customText,
inserted: deletedField.inserted,
};
return {
status: 200,
body: {
...remappedField,
documentId: Number(documentId),
},
};
}),
});

View File

@@ -0,0 +1,41 @@
import type { NextApiRequest } from 'next';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { Team, User } from '@documenso/prisma/client';
export const authenticatedMiddleware = <
T extends {
req: NextApiRequest;
},
R extends {
status: number;
body: unknown;
},
>(
handler: (args: T, user: User, team?: Team | null) => Promise<R>,
) => {
return async (args: T) => {
try {
const { authorization } = args.req.headers;
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new Error('Token was not provided for authenticated middleware');
}
const apiToken = await getApiTokenByToken({ token });
return await handler(args, apiToken.user, apiToken.team);
} catch (_err) {
console.log({ _err });
return {
status: 401,
body: {
message: 'Unauthorized',
},
} as const;
}
};
};

View File

@@ -0,0 +1,17 @@
import { generateOpenApi } from '@ts-rest/open-api';
import { ApiContractV1 } from './contract';
export const OpenAPIV1 = generateOpenApi(
ApiContractV1,
{
info: {
title: 'Documenso API',
version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
},
},
{
setOperationId: true,
},
);

241
packages/api/v1/schema.ts Normal file
View File

@@ -0,0 +1,241 @@
import { z } from 'zod';
import {
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
/**
* Documents
*/
export const ZGetDocumentsQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
});
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
export const ZDeleteDocumentMutationSchema = null;
export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema;
export const ZSuccessfulDocumentResponseSchema = z.object({
id: z.number(),
userId: z.number(),
teamId: z.number().nullish(),
title: z.string(),
status: z.string(),
documentDataId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
completedAt: z.date().nullable(),
});
export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseSchema.extend({
recipients: z.lazy(() => z.array(ZSuccessfulRecipientResponseSchema)),
});
export type TSuccessfulGetDocumentResponseSchema = z.infer<
typeof ZSuccessfulGetDocumentResponseSchema
>;
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
export const ZSendDocumentForSigningMutationSchema = null;
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
export const ZUploadDocumentSuccessfulSchema = z.object({
url: z.string(),
key: z.string(),
});
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z.string(),
})
.partial(),
});
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
export const ZCreateDocumentMutationResponseSchema = z.object({
uploadUrl: z.string().min(1),
documentId: z.number(),
recipients: z.array(
z.object({
recipientId: z.number(),
token: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
export type TCreateDocumentMutationResponseSchema = z.infer<
typeof ZCreateDocumentMutationResponseSchema
>;
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
title: z.string().min(1),
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z.string(),
})
.partial()
.optional(),
});
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationSchema
>;
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
});
export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationResponseSchema
>;
export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
});
/**
* Recipients
*/
export type TCreateRecipientMutationSchema = z.infer<typeof ZCreateRecipientMutationSchema>;
export const ZUpdateRecipientMutationSchema = ZCreateRecipientMutationSchema.partial();
export type TUpdateRecipientMutationSchema = z.infer<typeof ZUpdateRecipientMutationSchema>;
export const ZDeleteRecipientMutationSchema = null;
export type TDeleteRecipientMutationSchema = typeof ZDeleteRecipientMutationSchema;
export const ZSuccessfulRecipientResponseSchema = z.object({
id: z.number(),
// !: This handles the fact that we have null documentId's for templates
// !: while we won't need the default we must add it to satisfy typescript
documentId: z.number().nullish().default(-1),
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
token: z.string(),
// !: Not used for now
// expired: z.string(),
signedAt: z.date().nullable(),
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
sendStatus: z.nativeEnum(SendStatus),
});
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
/**
* Fields
*/
export const ZCreateFieldMutationSchema = z.object({
recipientId: z.number(),
type: z.nativeEnum(FieldType),
pageNumber: z.number(),
pageX: z.number(),
pageY: z.number(),
pageWidth: z.number(),
pageHeight: z.number(),
});
export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>;
export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial();
export type TUpdateFieldMutationSchema = z.infer<typeof ZUpdateFieldMutationSchema>;
export const ZDeleteFieldMutationSchema = null;
export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema;
export const ZSuccessfulFieldResponseSchema = z.object({
id: z.number(),
documentId: z.number(),
recipientId: z.number(),
type: z.nativeEnum(FieldType),
pageNumber: z.number(),
pageX: z.number(),
pageY: z.number(),
pageWidth: z.number(),
pageHeight: z.number(),
customText: z.string(),
inserted: z.boolean(),
});
export type TSuccessfulFieldResponseSchema = z.infer<typeof ZSuccessfulFieldResponseSchema>;
export const ZSuccessfulResponseSchema = z.object({
documents: ZSuccessfulDocumentResponseSchema.array(),
totalPages: z.number(),
});
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
export const ZSuccessfulSigningResponseSchema = z.object({
message: z.string(),
});
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
/**
* General
*/
export const ZAuthorizationHeadersSchema = z.object({
authorization: z.string(),
});
export type TAuthorizationHeadersSchema = z.infer<typeof ZAuthorizationHeadersSchema>;
export const ZUnsuccessfulResponseSchema = z.object({
message: z.string(),
});
export type TUnsuccessfulResponseSchema = z.infer<typeof ZUnsuccessfulResponseSchema>;

View File

@@ -15,7 +15,7 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill('sent');
await page.getByPlaceholder('Type a command or search...').first().fill('sent');
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
});
@@ -32,7 +32,7 @@ test('[PR-713]: should see received documents', async ({ page }) => {
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill('received');
await page.getByPlaceholder('Type a command or search...').first().fill('received');
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
});
@@ -49,6 +49,6 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => {
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
});

View File

@@ -107,6 +107,8 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
await page.waitForTimeout(1000);
}
await unseedTeam(team.url);
@@ -187,15 +189,18 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
await page.waitForTimeout(1000);
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);

View File

@@ -1,20 +1,19 @@
import { type Page, expect, test } from '@playwright/test';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import {
extractUserVerificationToken,
seedUser,
unseedUser,
unseedUserByEmail,
} from '@documenso/prisma/seed/users';
test.use({ storageState: { cookies: [], origins: [] } });
/*
Using them sequentially so the 2nd test
uses the details from the 1st (registration) test
*/
test.describe.configure({ mode: 'serial' });
const username = 'Test User';
const email = 'test-user@auth-flow.documenso.com';
const password = 'Password123#';
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
const username = 'Test User';
const email = `test-user-${Date.now()}@auth-flow.documenso.com`;
const password = 'Password123#';
await page.goto('/signup');
await page.getByLabel('Name').fill(username);
await page.getByLabel('Email').fill(email);
@@ -31,25 +30,33 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
}
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
await page.waitForURL('/unverified-account');
const { token } = await extractUserVerificationToken(email);
await page.goto(`/verify-email/${token}`);
await expect(page.getByRole('heading')).toContainText('Email Confirmed!');
await page.getByRole('link', { name: 'Go back home' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
await unseedUserByEmail(email);
});
test('user can login with user and password', async ({ page }: { page: Page }) => {
const user = await seedUser();
await page.goto('/signin');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
});
test.afterAll('Teardown', async () => {
try {
await deleteUser({ email });
} catch (e) {
throw new Error(`Error deleting user: ${e}`);
}
await unseedUser(user.id);
});

View File

@@ -0,0 +1,19 @@
export type DownloadFileOptions = {
filename: string;
data: Blob;
};
export const downloadFile = ({ filename, data }: DownloadFileOptions) => {
if (typeof window === 'undefined') {
throw new Error('downloadFile can only be called in browser environments');
}
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(data);
link.download = filename;
link.click();
window.URL.revokeObjectURL(link.href);
};

View File

@@ -1,6 +1,7 @@
import type { DocumentData } from '@documenso/prisma/client';
import { getFile } from '../universal/upload/get-file';
import { downloadFile } from './download-file';
type DownloadPDFProps = {
documentData: DocumentData;
@@ -14,16 +15,12 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
type: 'application/pdf',
});
const link = window.document.createElement('a');
const [baseTitle] = fileName?.includes('.pdf')
? fileName.split('.pdf')
: [fileName ?? 'document'];
link.href = window.URL.createObjectURL(blob);
link.download = `${baseTitle}_signed.pdf`;
link.click();
window.URL.revokeObjectURL(link.href);
downloadFile({
filename: baseTitle,
data: blob,
});
};

View File

@@ -0,0 +1,19 @@
import { DOCUMENT_EMAIL_TYPE } from '../types/document-audit-logs';
export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.SIGNING_REQUEST]: {
description: 'Signing request',
},
[DOCUMENT_EMAIL_TYPE.VIEW_REQUEST]: {
description: 'Viewing request',
},
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
description: 'Approval request',
},
[DOCUMENT_EMAIL_TYPE.CC]: {
description: 'CC',
},
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed',
},
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;

View File

@@ -22,6 +22,8 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_teams: true,
app_document_page_view_history_sheet: false,
marketing_header_single_player_mode: false,
} as const;

View File

@@ -1,29 +1,31 @@
import { RecipientRole } from '@documenso/prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION: {
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
} = {
export const RECIPIENT_ROLES_DESCRIPTION = {
[RecipientRole.APPROVER]: {
actionVerb: 'Approve',
actioned: 'Approved',
progressiveVerb: 'Approving',
roleName: 'Approver',
},
[RecipientRole.CC]: {
actionVerb: 'CC',
actioned: `CC'd`,
progressiveVerb: 'CC',
roleName: 'CC',
roleName: 'Cc',
},
[RecipientRole.SIGNER]: {
actionVerb: 'Sign',
actioned: 'Signed',
progressiveVerb: 'Signing',
roleName: 'Signer',
},
[RecipientRole.VIEWER]: {
actionVerb: 'View',
actioned: 'Viewed',
progressiveVerb: 'Viewing',
roleName: 'Viewer',
},
};
} satisfies Record<keyof typeof RecipientRole, unknown>;
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',

View File

@@ -1,5 +1,11 @@
import { Duration } from 'luxon';
export const ONE_SECOND = 1000;
export const ONE_MINUTE = ONE_SECOND * 60;
export const ONE_HOUR = ONE_MINUTE * 60;
export const ONE_DAY = ONE_HOUR * 24;
export const ONE_WEEK = ONE_DAY * 7;
export const ONE_MONTH = Duration.fromObject({ months: 1 });
export const THREE_MONTHS = Duration.fromObject({ months: 3 });
export const SIX_MONTHS = Duration.fromObject({ months: 6 });
export const ONE_YEAR = Duration.fromObject({ years: 1 });

View File

@@ -1,4 +1,4 @@
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';

View File

@@ -1,7 +1,7 @@
import { base32 } from '@scure/base';
import { TOTPController } from 'oslo/otp';
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';

View File

@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
export interface FindDocumentsOptions {
term?: string;

View File

@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { Role } from '@documenso/prisma/client';
import type { Role } from '@documenso/prisma/client';
export type UpdateUserOptions = {
id: number;

View File

@@ -1,4 +1,5 @@
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
import crypto from 'crypto';
import { SALT_ROUNDS } from '../../constants/auth';
@@ -12,3 +13,7 @@ export const hashSync = (password: string) => {
export const compareSync = (password: string, hash: string) => {
return bcryptCompareSync(password, hash);
};
export const hashString = (input: string) => {
return crypto.createHash('sha512').update(input).digest('hex');
};

View File

@@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
}
return upsertedDocumentMeta;
});

View File

@@ -10,80 +10,127 @@ import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
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';
export type DeleteDocumentOptions = {
id: number;
userId: number;
status: DocumentStatus;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
export const deleteDocument = async ({
id,
userId,
teamId,
requestMetadata,
}: DeleteDocumentOptions) => {
const document = await prisma.document.findUnique({
where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
documentMeta: true,
User: true,
},
});
if (!document) {
throw new Error('Document not found');
}
const { status, User: user } = document;
// if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) {
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
return await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
},
}),
});
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
});
}
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING) {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const document = await prisma.document.findUnique({
where: {
id,
status,
userId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
// If the document is not a draft, only soft-delete.
return await prisma.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'SOFT',
},
}),
});
return await tx.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
});
};

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