Compare commits

...

147 Commits

Author SHA1 Message Date
6f532208cb fix: use custom status widget 2024-03-21 14:35:29 +00:00
fa25f6d24e chore: add status widget 2024-03-21 13:40:48 +00:00
67beb8225c chore: minor update to open page (#1043) 2024-03-21 13:17:47 +01:00
94198e7584 chore: text 2024-03-21 13:16:17 +01:00
facafe0997 feat: place card titles in the box 2024-03-21 01:44:32 +00:00
8c1686f113 feat: add total signed documents 2024-03-21 01:25:23 +00:00
a8752098f6 fix: invalid datetime on graph 2024-03-21 00:48:49 +00:00
0dfdf36bda feat: restructure open page (#1040) 2024-03-20 12:48:44 +01:00
574cd176c2 chore: update copy to have more swag 2024-03-20 12:34:03 +01:00
48858cfdd0 chore: restructure open page 2024-03-20 10:31:19 +00:00
2facc0e331 feat: add completed documents per month graph 2024-03-20 10:17:31 +00:00
e7071f1f5a fix: username overflow issue (#1036)
Before:-
![Screenshot 2024-03-19
000255](https://github.com/documenso/documenso/assets/81948346/169bf207-3e0f-419c-bf72-ff25347c6814)

Now:-

![Screenshot 2024-03-18
235807](https://github.com/documenso/documenso/assets/81948346/17cd30d3-d6ef-4e31-b174-142c06491c4a)
2024-03-19 14:04:19 +11:00
b95f7176e2 fix: username overflow issue 2024-03-18 18:25:04 +00:00
6d754acfcd fix: disable edit signer inputs (#1035)
## Description

Update the add signer form to disable the signers when required.

I assume the actual issue is that `{...field}` was spreading a disabled
prop which was overriding our one.

## Changes Made

- Use fieldset to disable inputs
- Manually disable select since fieldset doesn't work for that select
for some reason
2024-03-18 19:59:39 +08:00
796456929e feat: improve lint-staged performance (#1006)
Right now, the eslint command runs separately for each staged file. This
PR aims to change that by running just one eslint command for all the
files that have been changed.
2024-03-18 15:18:20 +11:00
de9c9f4aab chore: tidying 2024-03-18 02:44:39 +00:00
b972056c8f Merge branch 'main' into improve-lint 2024-03-18 13:31:43 +11:00
69871e7d39 fix: update codespaces on create script (#1034)
## Description

Updates the `on-create.sh` script to use our `dx` command resolving
issues where it couldn't find the docker compose file due to changes
that have happened since publishing the Documenso docker image.

## Related Issue

Resolves #1026

## Test Details

N/A

## Checklist

- [x] I have written the new test and ensured it works as intended.
- [x] I have added necessary documentation to explain the purpose of the
test.
- [x] I have followed the project's testing guidelines and coding style.
- [x] I have addressed any review feedback from previous submissions, if
applicable.

## Additional Notes

N/A
2024-03-18 13:29:47 +11:00
9cfd769356 chore: use rust based cms signing (#1030) 2024-03-18 12:33:10 +11:00
bd20c5499f fix: update codespaces on create script 2024-03-18 01:28:26 +00:00
3c6cc7fd46 Merge branch 'main' into chore/add-rust-signer 2024-03-18 12:24:59 +11:00
4ac800fa78 feat: added custom dark mode styling for swagger ui (#1022)
**Description:**

Updated the OpenAPI doc dark mode styling here
https://app.documenso.com/api/v1/openapi

**Before:**

<img width="1473" alt="Screenshot 2024-03-13 at 09 48 36"
src="https://github.com/documenso/documenso/assets/23498248/a4fa1fef-699f-40e9-a06d-e513fc786399">

**After:**

<img width="1471" alt="Screenshot 2024-03-13 at 12 40 08"
src="https://github.com/documenso/documenso/assets/23498248/39c606b5-ab8b-4031-9821-a57c8bb80b7d">
2024-03-16 23:37:03 +11:00
3598bd0139 fix: use tailwind for menu switcher ring 2024-03-16 12:18:12 +00:00
d62838f4a0 fix: pagination discrepancy (#1024)
- This PR fixes the pagination discrepancy in the `DataTablePagination`
component.
ref #1021
2024-03-16 22:32:18 +11:00
8de8139b85 chore: send email to document owner (#1031)
**Description:**

This PR sends an email to the document owner once the signing has been
completed
2024-03-16 22:29:28 +11:00
a7594c9b3c fix: update sending logic 2024-03-16 11:06:33 +00:00
f012826b6b chore: send document owner email
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-15 22:26:31 +05:30
38752f95f3 Merge branch 'fix/pagination' of https://github.com/Gautam-Hegde/documenso into fix/pagination 2024-03-15 22:11:06 +05:30
4379b43ad9 chore: tidy code 2024-03-15 22:09:58 +05:30
8859b2779f chore: use rust based cms signing 2024-03-15 22:29:15 +11:00
e29bfbf5e0 chore: updated focus custom css
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-15 09:19:47 +05:30
17c6a4bd55 chore: updated focus state of menu switcher
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-15 09:18:51 +05:30
d6668ad204 Merge branch 'main' of https://github.com/documenso/documenso into feat/swagger-styling 2024-03-15 09:17:01 +05:30
91e1fe5e8f Merge branch 'main' into fix/pagination 2024-03-14 18:41:09 +05:30
fd4d5468cf fix: use gif for readme 2024-03-14 23:52:49 +11:00
d5c4885c67 fix: update signup form to handle password managers better 2024-03-14 12:39:58 +00:00
564f0dd945 fix: avoid opengraph image limit (#1027) 2024-03-14 23:27:19 +11:00
524a7918d5 fix: toss the signature 2024-03-14 10:41:59 +00:00
0db2e6643d fix: final final v2 2024-03-14 10:39:48 +00:00
f5967e28c3 fix: without protection? 2024-03-14 10:02:09 +00:00
4926b6de50 fix: boring sign/verify approach 2024-03-14 09:40:26 +00:00
d6c8a3d32c fix: what happens if we use a dynamic import? 2024-03-14 09:20:01 +00:00
a9bb559568 fix: avoid opengraph image limit 2024-03-14 10:56:46 +02:00
8d1da3df72 Merge branch 'main' of https://github.com/documenso/documenso into feat/swagger-styling 2024-03-14 09:53:48 +05:30
00c71fd66c chore: fixed focus ring
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-14 01:02:48 +05:30
df8d394c28 chore: updated to resolvedTheme
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-14 00:47:54 +05:30
bc3c9424c4 docs: add article about public api (#1005) 2024-03-13 21:23:44 +11:00
6643c4b9fc update author description 2024-03-13 12:00:42 +02:00
ec7b69f1a4 implement feedback 2024-03-13 11:59:12 +02:00
0488442652 fix: pagination discrepancy 2024-03-13 13:45:10 +05:30
cc483016d8 chore: updated styling
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-13 13:24:09 +05:30
025af6e9f4 chore: added eol
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-13 12:41:08 +05:30
e5497efe7c chore: updated dark mode styling
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-13 12:38:56 +05:30
27e7e51789 Merge branch 'main' of https://github.com/documenso/documenso into feat/swagger-styling 2024-03-13 09:56:16 +05:30
52afae331e chore: updated to send email to doc owners
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-13 09:50:37 +05:30
27a69819f9 feat: added custom styling for swagger ui
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-13 09:49:31 +05:30
a178e1d86f feat: add website cta (#1007)
![hover](https://github.com/documenso/documenso/assets/25515812/9132ef8c-f956-4370-8e20-cc01bc196664)

![notover](https://github.com/documenso/documenso/assets/25515812/a12e72f0-84eb-43bd-bb95-828fef8e8819)
2024-03-12 14:17:38 +11:00
1fd29f7e89 feat: require confirmation for user account deletion (#1009)
### This PR adds the necessary user friction in the Delete Dialog,
ensuring that users are intentionally deleting their accounts.

- User must disable 2FA to delete the account.


![2fa](https://github.com/documenso/documenso/assets/85569489/634fd9dd-2aea-4dd8-a231-ade82b71fc7d)

- Explicit user confirmation


![!2FA](https://github.com/documenso/documenso/assets/85569489/11a074b6-7ec7-4568-ba1a-ee884766047b)


fixes #998
2024-03-12 14:15:53 +11:00
d3f4e20f1c fix: update styling and e2e test 2024-03-12 02:57:22 +00:00
3ebeb347c5 chore: updated url regex (#1017)
**Description:**

- Fixes #1016 

**Scenarios Tested:**

- https://info.adikris.in
- https://www.info.adikris.in
- http://www.info.adikris.in
- https://adikris.in
- http://adikris.in
- https://adikris.com.au
2024-03-12 13:40:45 +11:00
f6c2b6c1c5 fix: minor updates 2024-03-12 01:52:16 +00:00
efb90ca5fb chore: use email confirmation 2024-03-11 23:17:11 +05:30
9ac346443d Merge branch 'main' into feat/add-website-cta 2024-03-11 13:06:46 +02:00
f2aa0cd714 Merge branch 'main' into feat/typeInDeletion 2024-03-11 15:20:19 +05:30
bbcb90d8a5 chore: updated url regex
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-03-11 15:00:58 +05:30
c2cf25b138 fix: templates incorrectly linking when in a team 2024-03-11 01:46:19 +00:00
63c23301a9 Merge branch 'main' into feat/typeInDeletion 2024-03-11 12:19:10 +11:00
1a23744d2a fix: table layout shift while changing tabs (#921)
fixes: #875 



https://github.com/documenso/documenso/assets/28510494/083fd87a-ef62-40e6-9696-9c04b4411502
2024-03-11 12:16:19 +11:00
7f31ab1ce3 fix: add scrollbar gutter property 2024-03-11 00:52:56 +00:00
7ac34099ee fix: ensure password input is cleared when 2fa enable dialog is closed (#1014)
Issue found by guatam in Documenso Discord.

https://discord.com/channels/1132216843537485854/1133420505030987857/1215716399822147664

Bug Loom:

https://www.loom.com/share/b7defde91dfb437580c13c7f166f59ff?sid=cbca746f-174a-44ad-997c-cf3a2eef8380

Fix Loom:

https://www.loom.com/share/8748ac47459247a49ddf7d5379e2a0a0?sid=02977122-c150-44e8-9a19-4c8356c298d7
2024-03-11 11:26:35 +11:00
c744482b84 fix: add conditional to useEffects 2024-03-11 10:55:46 +11:00
b71cb66dd3 fix: show close icon on notification in mobile (#996)
As per the requirement on the mobile, the close icon will always be
visible

On the desktop, close icon will be visible on hover.

fixes #965
2024-03-10 22:12:29 +11:00
afe99e5ec9 fix: revert reset changes, reset on open state change instead 2024-03-10 09:36:54 +00:00
6f958b9320 fix: update the dialog cancel to reset 2024-03-10 09:28:08 +00:00
f646fa29d7 fix: ensure password input is cleared when 2fa enable dialog is closed 2024-03-10 07:01:18 +00:00
373e806bd9 fix: eslint config file parseOptions.project path is updated (#1008)
I found out that the problem of slow down is the use of
parseOption.project with multiple tsconfig files which increases usage
of memory consumption quite a lot.

Here is the reference I found to resolve this issue:

[reference
link](https://github.com/typescript-eslint/typescript-eslint/issues/1192)
[Doc
link](https://typescript-eslint.io/getting-started/typed-linting/monorepos/)

Based on this reference I created the solution and tested out locally it
does reduce the eslint time.

#1002
2024-03-10 15:32:59 +11:00
c3c00dfe05 fix: update docker documentation and include cert env vars (#1013)
## Description

Updates the docker documentation and compose files to include key file
related configuration for self hosters. This was missed as the key file
environment variables weren't documented in the `.env.example` file
which was used for creating the docker documentation.

## Related Issue

#1012

## Changes Made

- Updated the docker configuration to include key file environment
variables
- Updated the .env.example to include key file env vars
- Updated the compose images to use volumes for key files

## Testing Performed

- I have used the containers with the updated values and confirmed that
they work as expected

## Checklist

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

## Additional Notes

N/A
2024-03-10 14:11:47 +11:00
9e1b2e5cc3 fix: update sharp dependency 2024-03-10 13:48:25 +11:00
608e622f69 fix: update testing compose config 2024-03-10 13:48:09 +11:00
415f79f821 fix: update docker docs and compose files 2024-03-10 11:13:05 +11:00
62b4a13d4d feat: upgrade packages 2024-03-09 00:32:08 +05:30
19714fb807 feat: update packages 2024-03-09 00:29:42 +05:30
7631c6e90e feat: add prettier to lint 2024-03-09 00:17:04 +05:30
d462ca0b46 feat: remove prettier plugin 2024-03-09 00:16:16 +05:30
b433225762 Update command.tsx 2024-03-08 22:12:05 +05:30
ad92b1ac23 feat: typeIn confirmation 2024-03-08 21:56:17 +05:30
a4806f933e Merge branch 'main' of https://github.com/Gautam-Hegde/documenso 2024-03-08 21:33:19 +05:30
3b5f8d149a fix: eslint config file parseOptions.project path is updated 2024-03-08 21:14:37 +05:30
e8b209eb82 fix: fixed cta component 2024-03-08 15:46:44 +02:00
4476cf8fd1 Merge branch 'main' into fix/layout-shift-on-table 2024-03-09 00:41:20 +11:00
0fdb7f7a8d fix: changed to card component 2024-03-08 15:30:08 +02:00
05aa52b44a fix: fixed the recipients viewing issue on touch screens (#773)
## Description

This pull request addresses the issue where users on touchscreen devices
couldn't properly see the Recipients list. I have implemented a two-easy
solution to address this problem, offering a seamless user experience
across device types:

1. Popover for Touchscreen: For touchscreen devices, I have added a
popover that displays the Recipients list when users tap on the avatars.

2. Tooltip for Larger Screens: On larger screens (desktops and laptops),
I have added a tooltip that appears when users hover over the avatars.

## Changes
1. Added `Popover` for smaller devices and keep the `Tooltip` for larger
devices.
2. Renamed the component `stack-avatars-wtih-tooltip` to
`stack-avatars-wtih-ui` because now it contains both Tooltip and
Popover.
3. Used the `useWindowSize` hook to conditionally render the `Tooltip`
and `Popover`
4. To avoid repeating the same code, I've created a new component named
`stack-avatars-components.tsx` to show the recipient's details. This
component uses both Popover and Tooltip.


## PR Preview



https://github.com/documenso/documenso/assets/87828904/2dc9b056-b4bd-43dd-b427-a0e803dee55a


## Issue

Closes #756
2024-03-08 23:59:11 +11:00
40343d1c72 fix: add use client directive 2024-03-08 12:34:49 +00:00
08201240d2 Merge branch 'main' into update-documents-avatar 2024-03-08 23:26:28 +11:00
32b0b1bcda fix: revert api change and use mouseenter/mouseleave 2024-03-08 12:21:32 +00:00
61ca34eee1 removed unused cn 2024-03-08 13:46:22 +02:00
41843691e8 feat: add website cta 2024-03-08 13:44:25 +02:00
c463d5a0ed fix: add double quote 2024-03-08 16:47:38 +05:30
8afe669978 feat: improve lint staged performance 2024-03-08 16:26:47 +05:30
fd4ea3aca5 fix: update docker docs 2024-03-08 20:00:24 +11:00
ee2cb0eedf docs: add article about public api 2024-03-08 10:20:58 +02:00
1b32c5a17f fix: fix blog post date (#1003)
Fixing the blog date for
https://documenso.com/blog/removing-server-actions
(I assume it was meant to be March 7th)


![image](https://github.com/documenso/documenso/assets/64188227/a7b96168-b094-46c0-877a-da26c9d140d4)
2024-03-08 09:27:30 +02:00
6376112f9d fix: overflow issue with user name input (#991)
Before:-

![Screenshot 2024-03-06
203158](https://github.com/documenso/documenso/assets/81948346/17050582-454b-49af-8124-294d0a0be5bc)

After:-

![Screenshot 2024-03-06
202332](https://github.com/documenso/documenso/assets/81948346/7c346699-3bff-4847-95ef-fd7fdc8a89af)
2024-03-08 15:24:17 +11:00
ddfd4b9e1b fix: update styling 2024-03-08 03:59:15 +00:00
5bec549868 feat: improved ui of document dropzone for max quota state (#997)
**Description:**


[Dropzone.webm](https://github.com/documenso/documenso/assets/23498248/df2d3a54-0e39-4d2d-b792-bf4cd4a1e19d)
2024-03-08 14:38:27 +11:00
2f728f401b chore: add e2e test for deleting a user (#1001) 2024-03-08 14:32:06 +11:00
bc60278bac fix: remove useless ternaries 2024-03-08 03:30:57 +00:00
e9664d6369 chore: tidy code 2024-03-08 03:23:27 +00:00
3b3346e6af fix: remove data-testid attributes 2024-03-08 13:47:59 +11:00
ee35b4a24b fix: update test to use getByRole 2024-03-08 02:46:25 +00:00
47b06fa290 Merge branch 'main' into test/delete-user 2024-03-08 13:30:24 +11:00
b9dccdb359 chore: updated code of conduct link (#999)
**Description:**

Updated broken link to code of conduct in the Readme
2024-03-08 12:54:23 +11:00
5e00280486 fix: add seed script to dx setup (#1000) 2024-03-08 12:53:08 +11:00
f0fd5506fc fix: skip seeding when running migrate dev
When prisma:migrate-dev needs to reset the database it will run the seed script to repopulate data. Now that we've added the seed script to our root setup command we will want to avoid this behaviour since we will end up double seeding the database which currently can cause issues.
2024-03-08 12:49:55 +11:00
ff3b49656c chore: remove unused function 2024-03-08 00:07:11 +00:00
e47ca1d6b6 chore: add e2e test for deleting a user 2024-03-08 00:04:27 +00:00
59cdf3203e fix: add seed script to dx setup 2024-03-07 20:09:29 +00:00
92f44cd304 chore: remove trailing slash
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-08 00:36:18 +05:30
3ce5b9e0a0 chore: updated code_of_conduct link
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-08 00:33:15 +05:30
e7f8f4e188 Merge branch 'main' of https://github.com/documenso/documenso into feat/doc-limit-improvement 2024-03-07 21:08:04 +05:30
6c9303012c chore: updated animation
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-07 21:06:16 +05:30
6fc3803ad2 Merge branch 'main' of https://github.com/Gautam-Hegde/documenso 2024-03-07 20:54:55 +05:30
f7e7c6dedf fix: overflow issue 2024-03-07 20:08:11 +05:30
0c426983bb chore: updated initial animation state
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-07 19:28:35 +05:30
9ae51a0072 feat: improved ui of document dropzone for max quota state
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-07 19:04:58 +05:30
d694f4a17b fix: show close icon on notification inmobile 2024-03-07 18:48:23 +05:30
80f767b321 chore: update docker section in readme 2024-03-07 23:36:34 +11:00
4ec8eeeac1 docs: add article on removing server actions (#994) 2024-03-07 23:11:32 +11:00
8e813ab2ac fix: use docker manifests for multiarch 2024-03-07 22:16:37 +11:00
51f60926ce fix: re-add buildx and qemu 2024-03-07 10:27:14 +00:00
7b77084dee fix: deleted files that shouldn't have been committed 2024-03-07 11:34:19 +02:00
a34334c4dc docs: add article on removing server actions 2024-03-07 11:32:01 +02:00
b5b74a788c fix: overflow issue with user name input 2024-03-06 14:52:59 +00:00
3106c325d7 Merge branch 'main' into fix/layout-shift-on-table 2024-02-20 21:52:35 +05:30
58f4b72939 added fixed width for status col 2024-02-08 13:31:38 +05:30
7b6d6fb1b9 Merge branch 'main' into update-documents-avatar 2024-01-29 19:52:29 +08:00
ab8c8e2a57 Merge branch 'main' of https://github.com/Gautam-Hegde/documenso 2024-01-24 12:08:21 +05:30
6e22eff5a1 feat: command grp border 2024-01-23 00:02:04 +05:30
98667dac15 chore: code tidy 2024-01-22 12:03:14 +05:30
1d4e78e579 Merge branch 'main' into update-documents-avatar 2024-01-09 20:33:31 +08:00
8e754405f8 Merge branch 'main' into update-documents-avatar 2024-01-04 20:45:39 +06:00
3fb711cb42 Merge branch 'update-documents-avatar' of https://github.com/ashrafchowdury/documenso into update-documents-avatar 2023-12-29 19:34:46 +08:00
8a8a5510cb Merge branch 'main' into update-documents-avatar 2023-12-29 19:13:02 +06:00
ce67de9a1c refactor: changed component name for better readability 2023-12-29 19:29:13 +08:00
cf5841a895 Merge branch 'main' of https://github.com/ashrafchowdury/documenso into update-documents-avatar 2023-12-29 18:48:43 +08:00
2bc5d15af2 Merge branch 'main' into update-documents-avatar 2023-12-28 10:43:51 +06:00
2a448fe06d fix: fixed the recipients viewing issue on touch screens 2023-12-20 17:08:09 +08:00
87 changed files with 5699 additions and 1292 deletions

View File

@ -1,13 +1,10 @@
#!/usr/bin/env bash
# Start the database and mailserver
docker compose -f ./docker/compose-without-app.yml up -d
# Install dependencies
npm install
# Copy the env file
cp .env.example .env
# Run the migrations
npm run prisma:migrate-dev
# Run the dev setup
npm run dx

View File

@ -22,10 +22,33 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[SIGNING]]
# The transport to use for document signing. Available options: local (default) | gcloud-hsm
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: The passphrase to use for the local file-based signing transport.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
# OPTIONAL: The local file path to the .p12 file to use for the local signing transport.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the .p12 file to use for the local signing transport.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud HSM key to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH=
# OPTIONAL: The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# [[SIGNING]]
# OPTIONAL: Defines the signing transport to use. Available options: local (default)
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: Defines the passphrase for the signing certificate.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
@ -90,6 +113,11 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# This is only required for the marketing site
# [[REDIS]]
NEXT_PRIVATE_REDIS_URL=

View File

@ -5,8 +5,15 @@ on:
branches: ['release']
jobs:
build:
runs-on: warp-ubuntu-latest-arm64-16x
build_and_publish_platform_containers:
name: Build and publish platform containers
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- warp-ubuntu-latest-x64-4x
- warp-ubuntu-latest-arm64-4x
steps:
- uses: actions/checkout@v4
@ -26,7 +33,101 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GH_TOKEN }}
- name: Build and push the docker image
run: ./docker/buildx-and-push.sh
- name: Build the docker image
env:
PLATFORM: 'linux/amd64,linux/arm64'
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker build \
-f ./docker/Dockerfile \
--progress=plain \
-t "documenso/documenso-$BUILD_PLATFORM:latest" \
-t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA" \
-t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION" \
.
- name: Push the docker image to DockerHub
run: docker push --all-tags "documenso/documenso-$BUILD_PLATFORM"
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
- name: Push the docker image to GitHub Container Registry
run: docker push --all-tags "ghcr.io/documenso/documenso-$BUILD_PLATFORM"
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
create_and_publish_manifest:
name: Create and publish manifest
runs-on: ubuntu-latest
needs: build_and_publish_platform_containers
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-tags: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GH_TOKEN }}
- name: Create and push DockerHub manifest
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker manifest create \
documenso/documenso:latest \
--amend documenso/documenso-amd64:latest \
--amend documenso/documenso-arm64:latest \
docker manifest create \
documenso/documenso:$GIT_SHA \
--amend documenso/documenso-amd64:$GIT_SHA \
--amend documenso/documenso-arm64:$GIT_SHA \
docker manifest create \
documenso/documenso:$APP_VERSION \
--amend documenso/documenso-amd64:$APP_VERSION \
--amend documenso/documenso-arm64:$APP_VERSION \
docker manifest push documenso/documenso:latest
docker manifest push documenso/documenso:$GIT_SHA
docker manifest push documenso/documenso:$APP_VERSION
- name: Create and push Github Container Registry manifest
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker manifest create \
ghcr.io/documenso/documenso:latest \
--amend ghcr.io/documenso/documenso-amd64:latest \
--amend ghcr.io/documenso/documenso-arm64:latest \
docker manifest create \
ghcr.io/documenso/documenso:$GIT_SHA \
--amend ghcr.io/documenso/documenso-amd64:$GIT_SHA \
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA \
docker manifest create \
ghcr.io/documenso/documenso:$APP_VERSION \
--amend ghcr.io/documenso/documenso-amd64:$APP_VERSION \
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION \
docker manifest push ghcr.io/documenso/documenso:latest
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION

View File

@ -1,7 +1,9 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
"source.fixAll": "explicit"
},
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",

View File

@ -27,20 +27,11 @@
<a href="https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso">
<img alt="open in devcontainer" src="https://img.shields.io/static/v1?label=Dev%20Containers&message=Enabled&color=blue&logo=visualstudiocode" />
</a>
<a href="code_of_conduct.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"></a>
<a href="CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"></a>
</p>
<div>
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/67e08c98-c153-4115-aa2d-77979bb12c94)">
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/040cfbae-3438-4ca3-87f2-ce52c793dcaf">
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/72d445be-41e5-4936-bdba-87ef8e70fa09">
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/d7b86c0f-a755-4476-a022-a608db2c4633">
<img style="display: block; height: 120px; width: 24%"
src=https://github.com/documenso/documenso/assets/1309312/c0f55116-ab82-433f-a266-f3fc8571d69f">
<div align="center">
<img src="https://github.com/documenso/documenso/assets/13398220/d96ed533-6f34-4a97-be9b-442bdb189c69" style="width: 80%;" />
</div>
## About this project
@ -207,7 +198,16 @@ If you're a visual learner and prefer to watch a video walkthrough of setting up
## Docker
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
We provide a Docker container for Documenso, which is published on both DockerHub and GitHub Container Registry.
- DockerHub: [https://hub.docker.com/r/documenso/documenso](https://hub.docker.com/r/documenso/documenso)
- GitHub Container Registry: [https://ghcr.io/documenso/documenso](https://ghcr.io/documenso/documenso)
You can pull the Docker image from either of these registries and run it with your preferred container hosting provider.
Please note that you will need to provide environment variables for connecting to the database, mailserver, and so forth.
For detailed instructions on how to configure and run the Docker container, please refer to the [Docker README](./docker/README.md) in the `docker` directory.
## Self Hosting

View File

@ -0,0 +1,292 @@
---
title: 'Building the Documenso Public API - The Why and How'
description: 'This article talks about the need for the public API and the process of building it. It also discusses the requirements we had to meet and the constraints we had to work within.'
authorName: 'Catalin'
authorImage: '/blog/blog-author-catalin.webp'
authorRole: 'I like to code and write'
date: 2024-03-08
tags:
- Development
- API
---
This article covers the process of building the public API for Documenso. It starts by explaining why the API was needed for a digital document signing company in the first place. Then, it'll dive into the steps we took to build it. Lastly, it'll present the requirements we had to meet and the constraints we had to work within.
## Why the public API
We decided to build the public API to open a new way of interacting with Documenso. While the web app does the job well, there are use cases where it's not enough. In those cases, the users might want to interact with the platform programmatically. Usually, that's for integrating Documenso with other applications.
With the new public API that's now possible. You can integrate Documenso's functionalities within other applications to automate tasks, create custom solutions, and build custom workflows, to name just a few.
The API provides 12 endpoints at the time of writing this article:
- (GET) `/api/v1/documents` - retrieve all the documents
- (POST) `/api/v1/documents` - upload a new document and getting a presigned URL
- (GET) `/api/v1/documents/{id}` - fetch a specific document
- (DELETE) `/api/v1/documents/{id}` - delete a specific document
- (POST) `/api/v1/templates/{templateId}/create-document` - create a new document from an existing template
- (POST) `/api/v1/documents/{id}/send` - send a document for signing
- (POST) `/api/v1/documents/{id}/recipients` - create a document recipient
- (PATCH) `/api/v1/documents/{id}/recipients/{recipientId}` - update the details of a document recipient
- (DELETE) `/api/v1/documents/{id}/recipients/{recipientId}` - delete a specific recipient from a document
- (POST) `/api/v1/documents/{id}/fields` - create a field for a document
- (PATCH) `/api/v1/documents/{id}/fields` - update the details of a document field
- (DELETE) `/api/v1/documents/{id}/fields` - delete a field from a document
> Check out the [API documentation](https://app.documenso.com/api/v1/openapi).
Moreover, it also enables us to enhance the platform by bringing other integrations to Documenso, such as Zapier.
In conclusion, the new public API extends Documenso's capabilities, provides more flexibility for users, and opens up a broader world of possibilities.
## Picking the right approach & tech
Once we decided to build the API, we had to choose the approach and technologies to use. There were 2 options:
1. Build an additional application
2. Launch the API in the existing codebase
### 1. Build an additional application
That would mean creating a new codebase and building the API from scratch. Having a separate app for the API would result in benefits such as:
- lower latency responses
- supporting larger field uploads
- separation between the apps (Documenso and the API)
- customizability and flexibility
- easier testing and debugging
This approach has significant benefits. However, one major drawback is that it requires additional resources.
We'd have to spend a lot of time just on the core stuff, such as building and configuring the basic server. After that, we'd spend time implementing the endpoints and authorization, among other things. When the building is done, there will be another application to deploy and manage. All of this would stretch our already limited resources.
So, we asked ourselves if there is another way of doing it without sacrificing the API quality and the developer experience.
### 2. Launch the API in the existing codebase
The other option was to launch the API in the existing codebase. Rather than writing everything from scratch, we could use most of our existing code.
Since we're using tRPC for our internal API (backend), we looked for solutions that work well with tRPC. We narrowed down the choices to:
- [trpc-openapi](https://github.com/jlalmes/trpc-openapi)
- [ts-rest](https://ts-rest.com/)
Both technologies allow you to build public APIs. The `trpc-openapi` technology allows you to easily turn tRPC procedures into REST endpoints. It's more like a plugin for tRPC.
On the other hand, `ts-rest` is more of a standalone solution. `ts-rest` enables you to create a contract for the API, which can be used both on the client and server. You can consume and implement the contract in your application, thus providing end-to-end type safety and RPC-like client.
> You can see a [comparison between trpc-openapi and ts-rest](https://catalins.tech/public-api-trpc/) here.
So, the main difference between the 2 is that `trpc-openapi` is like a plugin that extends tRPC's capabilities, whereas `ts-rest` provides the tools for building a standalone API.
### Our choice
After analyzing and comparing the 2 options, we decided to go with `ts-rest` because of its benefits. Here's a paragraph from the `ts-rest` documentation that hits the nail on the head:
> tRPC has many plugins to solve this issue by mapping the API implementation to a REST-like API, however, these approaches are often a bit clunky and reduce the safety of the system overall, ts-rest does this heavy lifting in the client and server implementations rather than requiring a second layer of abstraction and API endpoint(s) to be defined.
## API Requirements
We defined the following requirements for the API:
- The API should use path-based versioning (e.g. `/v1`)
- The system should use bearer tokens for API authentication
- The API token should be a random string of 32 to 40 characters
- The system should hash the token and store the hashed value
- The system should only display the API token when it's created
- The API should have self-generated documentation like Swagger
- Users should be able to create an API key
- Users should be able to choose a token name
- Users should be able to choose an expiration date for the token
- User should be able to choose between 7 days, 1 month, 3 months, 6 months, 12 months, never
- System should display all the user's tokens in the settings page
- System should display the token name, creation date, expiration date and a delete button
- Users should be able to delete an API key
- Users should be able to retrieve all the documents from their account
- Users should be able to upload a new document
- Users should receive an S3 pre-signed URL after a successful upload
- Users should be able to retrieve a specific document from their account by its id
- Users should be able to delete a specific document from their account by its id
- Users should be able to create a new document from an existing document template
- Users should be able to send a document for signing to 1 or more recipients
- Users should be able to create a recipient for a document
- Users should be able to update the details of a recipient
- Users should be able to delete a recipient from a document
- Users should be able to create a field (e.g. signature, email, name, date) for a document
- Users should be able to update a field for a document
- Users should be able to delete a field from a document
## Constraints
We also faced the following constraints while developing the API:
**1. Resources**
Limited resources were one of the main constraints. We're a new startup with a relatively small team. Building and maintaining an additional application would strain our limited resources.
**2. Technology stack**
Another constraint was the technology stack. Our tech stack includes TypeScript, Prisma, and tRPC, among others. We also use Vercel for hosting.
As a result, we wanted to use technologies we are comfortable with. This allowed us to leverage our existing knowledge and ensured consistency across our applications.
Using familiar technologies also meant we could develop the API faster, as we didn't have to spend time learning new technologies. We could also leverage existing code and tools used in our main application.
It's worth mentioning that this is not a permanent decision. We're open to moving the API to another codebase/tech stack when it makes sense (e.g. API is heavily used and needs better performance).
**3. File uploads**
Due to our current architecture, we support file uploads with a maximum size of 50 MB. To circumvent this, we created an additional step for uploading documents.
Users make a POST request to the `/api/v1/documents` endpoint and the API responds with an S3 pre-signed URL. The users then make a 2nd request to the pre-signed URL with their document.
## How we built the API
![API package diagram](api-package.webp)
Our codebase is a monorepo, so we created a new API package in the `packages` directory. It contains both the API implementation and its documentation. The main 2 blocks of the implementation consist of the API contract and the code for the API endpoints.
![API implementation diagram](api-implementation.webp)
In a few words, the API contract defines the API structure, the format of the requests and responses, how to authenticate API calls, the available endpoints and their associated HTTP verbs. You can explore the [API contract](https://github.com/documenso/documenso/blob/main/packages/api/v1/contract.ts) on GitHub.
Then, there's the implementation part, which is the actual code for each endpoint defined in the API contract. The implementation is where the API contract is brought to life and made functional.
Let's take the endpoint `/api/v1/documents` as an example.
```ts
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',
},
...
}
);
```
The API contract specifies the following things for `getDocuments`:
- the allowed HTTP request method is GET, so trying to make a POST request, for example, results in an error
- the path is `/api/v1/documents`
- the query parameters the user can pass with the request
- in this case - `page` and `perPage`
- the allowed responses and their schema
- `200` returns an object containing an array of all documents and a field `totalPages`, which is self-explanatory
- `401` returns an object with a message such as "Unauthorized"
- `404` returns an object with a message such as "Not found"
The implementation of this endpoint needs to match the contract completely; otherwise, `ts-rest` will complain, and your API might not work as intended.
The `getDocuments` function from the `implementation.ts` file runs when the user hits the endpoint.
```ts
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,
},
};
}),
...
});
```
There is a middleware, too, `authenticatedMiddleware`, that handles the authentication for API requests. It ensures that the API token exists and the token used has the appropriate privileges for the resource it accesses.
That's how the other endpoints work as well. The code differs, but the principles are the same. You can explore the [API implementation](https://github.com/documenso/documenso/blob/main/packages/api/v1/implementation.ts) and the [middleware code](https://github.com/documenso/documenso/blob/main/packages/api/v1/middleware/authenticated.ts) on GitHub.
### Documentation
For the documentation, we decided to use Swagger UI, which automatically generates the documentation from the OpenAPI specification.
The OpenAPI specification describes an API containing the available endpoints and their HTTP request methods, authentication methods, and so on. Its purpose is to help both machines and humans understand the API without having to look at the code.
The Documenso OpenAPI specification is live [here](https://documenso.com/api/v1/openapi.json).
Thankfully, `ts-rest` makes it seamless to generate the OpenAPI specification.
```ts
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,
},
);
```
Then, the Swagger UI takes the OpenAPI specification as a prop and generates the documentation. The code below shows the component responsible for generating the documentation.
```ts
'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} />;
};
export default OpenApiDocsPage;
```
Lastly, we create an API endpoint to display the Swagger documentation. The code below dynamically imports the `OpenApiDocsPage` component and displays it.
```ts
'use client';
import dynamic from 'next/dynamic';
const Docs = dynamic(async () => import('@documenso/api/v1/api-documentation'), {
ssr: false,
});
export default function OpenApiDocsPage() {
return <Docs />;
}
```
You can access and play around with the documentation at [documenso.com/api/v1/openapi](https://documenso.com/api/v1/openapi). You should see a page like the one shown in the screenshot below.
![The documentation for the Documenso API](docs.webp)
> This article shows how to [generate Swagger documentation for a Next.js API](https://catalins.tech/generate-swagger-documentation-next-js-api/).
So, that's how we went about building the first iteration of the public API after taking into consideration all the constraints and the current needs. The [GitHub pull request for the API](https://github.com/documenso/documenso/pull/674) is publicly available on GitHub. You can go through it at your own pace.
## Conclusion
The current architecture and approach work well for our current stage and needs. However, as we continue to grow and evolve, our architecture and approach will likely need to adapt. We monitor API usage and performance regularly and collect feedback from users. This enables us to find areas for improvement, understand our users' needs, and make informed decisions about the next steps.

View File

@ -0,0 +1,56 @@
---
title: 'Embracing the Future and Moving Back Again: From Server Actions to tRPC'
description: 'This article talks about the need for the public API and the process of building it. It also talks about the requirements, constraints, challenges and lessons learnt from building it.'
authorName: 'Lucas Smith'
authorImage: '/blog/blog-author-lucas.png'
authorRole: 'Co-Founder'
date: 2024-03-07
tags:
- Development
---
On October 26, 2022, during the Next.js Conf, Vercel unveiled Next.js 13, introducing a bunch of new features and a whole new paradigm shift for creating Next.js apps.
React Server Components (RSCs) have been known for some time but haven't yet been implemented in a React meta-framework. With Next.js now adding them, the world of server-side rendering React applications was about to change.
## The promise of React Server Components
Described by Vercel as a tool for writing UIs rendered or optionally cached on the server, RSC promised direct integration with databases and server-specific capabilities, a departure from the typical React and SSR models.
This feature initially appeared as a game-changer, promising simpler data fetching and routing, thanks to async server components and additional nested layout support.
After experimenting with it for about six months on other projects, I came to the conclusion that while it was a little rough at the edges, it was indeed a game changer and something that should be adopted in Next.js projects moving forward.
## A new new paradigm: Server Actions
Vercel didn't stop at adding Server Components. A short while after the initial Next.js 13 release, they introduced "Server Actions." Server Actions allow for the calling of server-side functions without API routes, reducing the amount of ceremony required for adding a new mutation or event to your application.
## Betting on new technology
Following the release of both Server Actions and Server Components, we at Documenso embarked on a rewrite of the application. We had accumulated a bit of technical debt from the initial MVP, which was best shed as we also improved the design with help from our new designer.
Having had much success with Server Components on other projects, I opted to go all in on both Server Components and Server Actions for the new version of the application. I was excited to see how much simpler and more efficient the application would be with these new technologies.
Following our relaunch, we felt quite happy with our choices of server actions, which even let us cocktail certain client—and server-side logic into a single component without needing a bunch of effort on our end.
## Navigating challenges with Server Actions
While initially successful, we soon encountered issues with Server Actions, particularly when monitoring the application. Unfortunately, server actions make it much harder to monitor network requests and identify failed server actions since they use the route that they are currently on.
Despite this issue, things were manageable; however, a critical issue arose when the usage of server actions caused bundle corruption, resulting in a top-level await insertion that would cause builds and tests to fail.
This last issue was a deal breaker since it highlighted how much magic we were relying on with server actions. I like to avoid adding more magic where possible since, with bundlers and meta-frameworks, we are already handing off a lot of heavy lifting and getting a lot of magic in return.
## Going all in on tRPC
My quick and final solution to the issues we were facing was to fully embrace [tRPC](https://trpc.io/), which we already used in other app areas. The migration took less than a day and resolved all our issues while adding a lot more visibility to failing actions since they now had a dedicated API route.
For those unfamiliar with tRPC, it is a framework for building end-to-end typesafe APIs with TypeScript and Next.js. It's an awesome library that lets you call your defined API safely within the client, causing build time errors when things inevitably drift.
I've been a big fan of tRPC for some time now, so the decision to drop server actions and instead use more of tRPC was easy for me.
## Lessons learned and the future
While I believe server actions are a great idea, and I'm sure they will be improved in the future, I've relearned the lesson that it's best to avoid magic where possible.
This doesn't mean we won't consider moving certain things back to server actions in the future. But for now, we will wait and watch how others use them and how they evolve.

View File

@ -12,6 +12,7 @@ export const BlogPost = defineDocumentType(() => ({
authorName: { type: 'string', required: true },
authorImage: { type: 'string', required: false },
authorRole: { type: 'string', required: true },
cta: { type: 'boolean', required: false, default: true },
},
computedFields: {
href: { type: 'string', resolve: (post) => `/${post._raw.flattenedPath}` },

View File

@ -21,7 +21,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
const config = {
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt'],
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
serverActions: {
bodySizeLimit: '50mb',
},

View File

@ -19,6 +19,7 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@openstatus/react": "^0.0.3",
"contentlayer": "^0.3.4",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
@ -36,7 +37,7 @@
"react-hook-form": "^7.43.9",
"react-icons": "^4.11.0",
"recharts": "^2.7.2",
"sharp": "0.33.1",
"sharp": "^0.33.1",
"typescript": "5.2.2",
"zod": "^3.22.4"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@ -1,25 +1,21 @@
import { ImageResponse } from 'next/og';
import { allBlogPosts } from 'contentlayer/generated';
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export const contentType = 'image/png';
export const IMAGE_SIZE = {
const IMAGE_SIZE = {
width: 1200,
height: 630,
};
type BlogPostOpenGraphImageProps = {
params: { post: string };
};
export async function GET(_request: Request) {
const url = new URL(_request.url);
export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGraphImageProps) {
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
const title = url.searchParams.get('title');
const author = url.searchParams.get('author');
if (!blogPost) {
return null;
if (!title || !author) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
@ -49,10 +45,10 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
<img src={logoImage} alt="logo" tw="h-8" />
<h1 tw="mt-8 text-6xl text-center flex items-center justify-center w-full max-w-[800px] font-bold text-center mx-auto">
{blogPost.title}
{title}
</h1>
<p tw="font-normal">Written by {blogPost.authorName}</p>
<p tw="font-normal">Written by {author}</p>
</div>
),
{

View File

@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
import { CallToAction } from '~/components/(marketing)/call-to-action';
export const dynamic = 'force-dynamic';
export const generateMetadata = ({ params }: { params: { post: string } }) => {
@ -18,11 +20,23 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
};
}
// Use the url constructor to ensure that things are escaped as they should be
const searchParams = new URLSearchParams({
title: blogPost.title,
author: blogPost.authorName,
});
return {
title: {
absolute: `${blogPost.title} - Documenso Blog`,
},
description: blogPost.description,
openGraph: {
images: [`${blogPost.href}/opengraph?${searchParams.toString()}`],
},
twitter: {
images: [`${blogPost.href}/opengraph?${searchParams.toString()}`],
},
};
};
@ -42,53 +56,57 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
const MDXContent = useMDXComponent(post.body.code);
return (
<article className="prose dark:prose-invert mx-auto py-8">
<div className="mb-6 text-center">
<time dateTime={post.date} className="text-muted-foreground mb-1 text-xs">
{new Date(post.date).toLocaleDateString()}
</time>
<div>
<article className="prose dark:prose-invert mx-auto py-8">
<div className="mb-6 text-center">
<time dateTime={post.date} className="text-muted-foreground mb-1 text-xs">
{new Date(post.date).toLocaleDateString()}
</time>
<h1 className="text-3xl font-bold">{post.title}</h1>
<h1 className="text-3xl font-bold">{post.title}</h1>
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
<div className="bg-foreground h-10 w-10 rounded-full">
{post.authorImage && (
<img
src={post.authorImage}
alt={`Image of ${post.authorName}`}
className="bg-foreground/10 h-10 w-10 rounded-full"
/>
)}
</div>
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
<div className="bg-foreground h-10 w-10 rounded-full">
{post.authorImage && (
<img
src={post.authorImage}
alt={`Image of ${post.authorName}`}
className="bg-foreground/10 h-10 w-10 rounded-full"
/>
)}
</div>
<div className="text-sm leading-6">
<p className="text-foreground text-left font-semibold">{post.authorName}</p>
<p className="text-muted-foreground">{post.authorRole}</p>
<div className="text-sm leading-6">
<p className="text-foreground text-left font-semibold">{post.authorName}</p>
<p className="text-muted-foreground">{post.authorRole}</p>
</div>
</div>
</div>
</div>
<MDXContent components={mdxComponents} />
<MDXContent components={mdxComponents} />
{post.tags.length > 0 && (
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
{post.tags.map((tag, i) => (
<li
key={`tag-${i}`}
className="bg-muted hover:bg-muted/60 text-foreground relative z-10 whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium"
>
{tag}
</li>
))}
</ul>
)}
{post.tags.length > 0 && (
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
{post.tags.map((tag, i) => (
<li
key={`tag-${i}`}
className="bg-muted hover:bg-muted/60 text-foreground relative z-10 whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium"
>
{tag}
</li>
))}
</ul>
)}
<hr />
<hr />
<Link href="/blog" className="text-muted-foreground flex items-center hover:opacity-60">
<ChevronLeft className="mr-2 h-6 w-6" />
Back to all posts
</Link>
</article>
<Link href="/blog" className="text-muted-foreground flex items-center hover:opacity-60">
<ChevronLeft className="mr-2 h-6 w-6" />
Back to all posts
</Link>
</article>
{post.cta && <CallToAction className="mt-8" utmSource={`blog_${params.post}`} />}
</div>
);
}

View File

@ -1,78 +1,14 @@
'use client';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header';
import { LayoutHeader } from '~/components/(marketing)/layout-header';
export type MarketingLayoutProps = {
children: React.ReactNode;
};
export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
})}
>
<div
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
{showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
<div className="absolute inset-0 -z-[1]">
<Image
src={launchWeekTwoImage}
className="h-full w-full object-cover"
alt="Launch Week 2"
/>
</div>
<div className="text-background text-center text-sm text-white">
Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
<a
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=marketing-announcement-bar`}
className="bg-background text-foreground rounded-md px-2.5 py-1 text-xs font-medium duration-300"
>
Claim Now
</a>
</div>
</div>
</div>
)}
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
<div className="relative flex min-h-[100vh] max-w-[100vw] flex-col overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
<LayoutHeader />
<div className="relative max-w-screen-xl flex-1 px-4 sm:mx-auto lg:px-8">{children}</div>

View File

@ -1,11 +1,10 @@
'use client';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { formatMonth } from '@documenso/lib/client-only/format-month';
import { cn } from '@documenso/ui/lib/utils';
export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & {
data: T;
@ -34,13 +33,13 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
.reverse();
return (
<div className={cn('flex flex-col', className)} {...props}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">{title}</h3>
<span>{extraInfo}</span>
</div>
<div className={className} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">{title}</h3>
<span>{extraInfo}</span>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />

View File

@ -5,8 +5,6 @@ import { useEffect, useState } from 'react';
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
import { cn } from '@documenso/ui/lib/utils';
import { CAP_TABLE } from './data';
const COLORS = ['#7fd843', '#a2e771', '#c6f2a4'];
@ -49,10 +47,12 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
setIsSSR(false);
}, []);
return (
<div className={cn('flex flex-col', className)} {...props}>
<h3 className="px-4 text-lg font-semibold">Cap Table</h3>
<div className={className} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Cap Table</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border shadow-sm hover:shadow">
{!isSSR && (
<PieChart width={400} height={400}>
<Pie

View File

@ -1,11 +1,10 @@
'use client';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { formatMonth } from '@documenso/lib/client-only/format-month';
import { cn } from '@documenso/ui/lib/utils';
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
data: Record<string, string | number>[];
@ -18,10 +17,12 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
}));
return (
<div className={cn('flex flex-col', className)} {...props}>
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
<div className={className} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Funding Raised</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
<XAxis dataKey="date" />

View File

@ -0,0 +1,56 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
export type MonthlyCompletedDocumentsChartProps = {
className?: string;
data: GetUserMonthlyGrowthResult;
};
export const MonthlyCompletedDocumentsChart = ({
className,
data,
}: MonthlyCompletedDocumentsChartProps) => {
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Completed Documents per Month</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Completed Documents']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="Completed Documents"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyNewUsersChartProps = {
className?: string;
@ -20,12 +19,12 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
});
return (
<div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">New Users</h3>
</div>
<div className={className}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">New Users</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />

View File

@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyTotalUsersChartProps = {
className?: string;
@ -20,12 +19,12 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
});
return (
<div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">Total Users</h3>
</div>
<div className={className}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Users</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />

View File

@ -2,19 +2,23 @@ import type { Metadata } from 'next';
import { z } from 'zod';
import { getCompletedDocumentsMonthly } from '@documenso/lib/server-only/user/get-monthly-completed-document';
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
import { MetricCard } from '~/app/(marketing)/open/metric-card';
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
import { CallToAction } from '~/components/(marketing)/call-to-action';
import { BarMetric } from './bar-metrics';
import { CapTable } from './cap-table';
import { FundingRaised } from './funding-raised';
import { MetricCard } from './metric-card';
import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart';
import { MonthlyNewUsersChart } from './monthly-new-users-chart';
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
import { SalaryBands } from './salary-bands';
import { TeamMembers } from './team-members';
import { OpenPageTooltip } from './tooltip';
import { TotalSignedDocumentsChart } from './total-signed-documents-chart';
import { Typefully } from './typefully';
export const metadata: Metadata = {
@ -130,125 +134,149 @@ export default async function OpenPage() {
{ total_count: mergedPullRequests },
STARGAZERS_DATA,
EARLY_ADOPTERS_DATA,
MONTHLY_USERS,
MONTHLY_COMPLETED_DOCUMENTS,
] = await Promise.all([
fetchGithubStats(),
fetchOpenIssues(),
fetchMergedPullRequests(),
fetchStargazers(),
fetchEarlyAdopters(),
getUserMonthlyGrowth(),
getCompletedDocumentsMonthly(),
]);
const MONTHLY_USERS = await getUserMonthlyGrowth();
return (
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
<div>
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you. You can read more about why here:{' '}
<a
className="font-bold"
href="https://documenso.com/blog/pre-seed"
target="_blank"
rel="noreferrer"
>
Announcing Open Metrics
</a>
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you. You can read more about why here:{' '}
<a
className="font-bold"
href="https://documenso.com/blog/pre-seed"
target="_blank"
rel="noreferrer"
>
Announcing Open Metrics
</a>
</p>
</div>
<div className="my-12 grid grid-cols-12 gap-8">
<div className="col-span-12 grid grid-cols-4 gap-4">
<MetricCard
className="col-span-2 lg:col-span-1"
title="Stargazers"
value={stargazersCount.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Forks"
value={forksCount.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Open Issues"
value={openIssues.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Merged PR's"
value={mergedPullRequests.toLocaleString('en-US')}
/>
</div>
<TeamMembers className="col-span-12" />
<SalaryBands className="col-span-12" />
</div>
<h2 className="px-4 text-2xl font-semibold">Finances</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
<CapTable className="col-span-12 lg:col-span-6" />
</div>
<h2 className="px-4 text-2xl font-semibold">Community</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="stars"
title="GitHub: Total Stars"
label="Stars"
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="mergedPRs"
title="GitHub: Total Merged PRs"
label="Merged PRs"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="forks"
title="GitHub: Total Forks"
label="Forks"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="openIssues"
title="GitHub: Total Open Issues"
label="Open Issues"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<Typefully className="col-span-12 lg:col-span-6" />
</div>
<h2 className="px-4 text-2xl font-semibold">Growth</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<BarMetric<EarlyAdoptersType>
data={EARLY_ADOPTERS_DATA}
metricKey="earlyAdopters"
title="Early Adopters"
label="Early Adopters"
className="col-span-12 lg:col-span-6"
extraInfo={<OpenPageTooltip />}
/>
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<MonthlyCompletedDocumentsChart
data={MONTHLY_COMPLETED_DOCUMENTS}
className="col-span-12 lg:col-span-6"
/>
<TotalSignedDocumentsChart
data={MONTHLY_COMPLETED_DOCUMENTS}
className="col-span-12 lg:col-span-6"
/>
</div>
</div>
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
<h2 className="text-2xl font-bold">Is there more?</h2>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
This page is evolving as we learn what makes a great signing company. We'll update it when
we have more to share.
</p>
</div>
<div className="mt-12 grid grid-cols-12 gap-8">
<div className="col-span-12 grid grid-cols-4 gap-4">
<MetricCard
className="col-span-2 lg:col-span-1"
title="Stargazers"
value={stargazersCount.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Forks"
value={forksCount.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Open Issues"
value={openIssues.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Merged PR's"
value={mergedPullRequests.toLocaleString('en-US')}
/>
</div>
<TeamMembers className="col-span-12" />
<SalaryBands className="col-span-12" />
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
<CapTable className="col-span-12 lg:col-span-6" />
<BarMetric<EarlyAdoptersType>
data={EARLY_ADOPTERS_DATA}
metricKey="earlyAdopters"
title="Early Adopters"
label="Early Adopters"
className="col-span-12 lg:col-span-6"
extraInfo={<OpenPageTooltip />}
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="stars"
title="Github: Total Stars"
label="Stars"
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="mergedPRs"
title="Github: Total Merged PRs"
label="Merged PRs"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="forks"
title="Github: Total Forks"
label="Forks"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="openIssues"
title="Github: Total Open Issues"
label="Open Issues"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<Typefully className="col-span-12 lg:col-span-6" />
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
<h2 className="text-2xl font-bold">Where's the rest?</h2>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
We're still working on getting all our metrics together. We'll update this page as soon
as we have more to share.
</p>
</div>
</div>
<CallToAction className="mt-12" utmSource="open-page" />
</div>
);
}

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils';
import {

View File

@ -0,0 +1,56 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
export type TotalSignedDocumentsChartProps = {
className?: string;
data: GetUserMonthlyGrowthResult;
};
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Completed Documents</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [
Number(value).toLocaleString('en-US'),
'Total Completed Documents',
]}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="Total Completed Documents"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -6,18 +6,19 @@ import Link from 'next/link';
import { FaXTwitter } from 'react-icons/fa6';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type TypefullyProps = HTMLAttributes<HTMLDivElement>;
export const Typefully = ({ className, ...props }: TypefullyProps) => {
return (
<div className={cn('flex flex-col', className)} {...props}>
<h3 className="px-4 text-lg font-semibold">Twitter Stats</h3>
<div className={className} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Twitter Stats</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border py-8 shadow-sm hover:shadow">
<div className="flex flex-col items-center gap-y-4 text-center">
<div className="my-12 flex flex-col items-center gap-y-4 text-center">
<FaXTwitter className="h-12 w-12" />
<Link href="https://typefully.com/documenso/stats" target="_blank">
<h1>Documenso on X</h1>

View File

@ -184,83 +184,85 @@ export const SinglePlayerClient = () => {
};
return (
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<div className="overflow-x-auto overflow-y-hidden">
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
free account
</Link>{' '}
or view our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
early adopter plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer
documentData={{
id: '',
data: uploadedFile.fileBase64,
initialData: uploadedFile.fileBase64,
type: DocumentDataType.BYTES_64,
}}
/>
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
free account
</Link>{' '}
or view our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
early adopter plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="top-24 lg:h-[calc(100vh-7rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer
documentData={{
id: '',
data: uploadedFile.fileBase64,
initialData: uploadedFile.fileBase64,
type: DocumentDataType.BYTES_64,
}}
/>
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="top-24 lg:h-[calc(100vh-7rem)]"
onSubmit={(e) => e.preventDefault()}
>
{/* Add fields to PDF page. */}
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
>
{/* Add fields to PDF page. */}
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
{/* Enter user details and signature. */}
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
fields={fields}
onSubmit={onFieldsSubmit}
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'))}
/>
</fieldset>
{/* Enter user details and signature. */}
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
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>
</DocumentFlowFormContainer>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,31 @@
import Link from 'next/link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
type CallToActionProps = {
className?: string;
utmSource?: string;
};
export const CallToAction = ({ className, utmSource = 'generic-cta' }: CallToActionProps) => {
return (
<Card spotlight className={className}>
<CardContent className="flex flex-col items-center justify-center p-12">
<h2 className="text-center text-2xl font-bold">Join the Open Signing Movement</h2>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center leading-normal">
Create your account and start using state-of-the-art document signing. Open and beautiful
signing is within your grasp.
</p>
<Button className="mt-8 rounded-full no-underline" size="lg" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=${utmSource}`} target="_blank">
Get started
</Link>
</Button>
</CardContent>
</Card>
);
};

View File

@ -53,7 +53,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" />
Star on Github
Star on GitHub
{starCount && starCount > 0 && (
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
{starCount.toLocaleString('en-US')}

View File

@ -1,5 +1,3 @@
'use client';
import type { HTMLAttributes } from 'react';
import Image from 'next/image';
@ -13,6 +11,8 @@ import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
import { StatusWidget } from './status-widget';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
const SOCIAL_LINKS = [
@ -62,6 +62,10 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</Link>
))}
</div>
<div className="mt-6">
<StatusWidget />
</div>
</div>
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">

View File

@ -123,7 +123,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" />
Star on Github
Star on GitHub
</Button>
</Link>
</motion.div>

View File

@ -0,0 +1,65 @@
'use client';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Header } from '~/components/(marketing)/header';
export function LayoutHeader() {
const [scrollY, setScrollY] = useState(0);
const { getFlag } = useFeatureFlags();
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
{showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
<div className="absolute inset-0 -z-[1]">
<Image
src={launchWeekTwoImage}
className="h-full w-full object-cover"
alt="Launch Week 2"
/>
</div>
<div className="text-background text-center text-sm text-white">
Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
<a
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=marketing-announcement-bar`}
className="bg-background text-foreground rounded-md px-2.5 py-1 text-xs font-medium duration-300"
>
Claim Now
</a>
</div>
</div>
</div>
)}
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
);
}

View File

@ -0,0 +1,73 @@
import type { Status } from '@openstatus/react';
import { getStatus } from '@openstatus/react';
import { cn } from '@documenso/ui/lib/utils';
const getStatusLevel = (level: Status) => {
return {
operational: {
label: 'Operational',
color: 'bg-green-500',
color2: 'bg-green-400',
},
degraded_performance: {
label: 'Degraded Performance',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
partial_outage: {
label: 'Partial Outage',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
major_outage: {
label: 'Major Outage',
color: 'bg-red-500',
color2: 'bg-red-400',
},
unknown: {
label: 'Unknown',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
incident: {
label: 'Incident',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
under_maintenance: {
label: 'Under Maintenance',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
}[level];
};
export async function StatusWidget() {
const { status } = await getStatus('documenso');
const level = getStatusLevel(status);
return (
<a
className="border-border inline-flex max-w-fit items-center justify-between gap-2 space-x-2 rounded-md border border-gray-200 px-3 py-1 text-sm"
href="https://status.documenso.com"
target="_blank"
rel="noreferrer"
>
<div>
<p className="text-sm">{level.label}</p>
</div>
<span className="relative ml-auto flex h-1.5 w-1.5">
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
level.color2,
)}
/>
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', level.color)} />
</span>
</a>
);
}

View File

@ -22,7 +22,7 @@ const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt'],
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
serverActions: {
bodySizeLimit: '50mb',
},

View File

@ -43,7 +43,7 @@
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"remeda": "^1.27.1",
"sharp": "0.33.1",
"sharp": "^0.33.1",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",

View File

@ -76,14 +76,13 @@ export const DocumentsDataTable = ({
{
header: 'Recipient',
accessorKey: 'recipient',
cell: ({ row }) => {
return <StackAvatarsWithTooltip recipients={row.original.Recipient} />;
},
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
size: 140,
},
{
header: 'Actions',

View File

@ -2,7 +2,6 @@
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
@ -139,27 +138,6 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
{team?.id === undefined && remaining.documents === 0 && (
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
<div className="text-center">
<h2 className="text-muted-foreground/80 text-xl font-semibold">
You have reached your document limit.
</h2>
<p className="text-muted-foreground/60 mt-2 text-sm">
You can upload up to {quota.documents} documents per month on your current plan.
</p>
<Link
className="text-primary hover:text-primary/80 mt-6 block font-medium"
href="/settings/billing"
>
Upgrade your account to upload more documents.
</Link>
</div>
</div>
)}
</div>
);
};

View File

@ -1,5 +1,7 @@
'use client';
import { useState } from 'react';
import { signOut } from 'next-auth/react';
import type { User } from '@documenso/prisma/client';
@ -16,6 +18,8 @@ import {
DialogTitle,
DialogTrigger,
} 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';
export type DeleteAccountDialogProps = {
@ -28,6 +32,8 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const [enteredEmail, setEnteredEmail] = useState<string>('');
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
@ -76,10 +82,11 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
</div>
<div className="flex-shrink-0">
<Dialog>
<Dialog onOpenChange={() => setEnteredEmail('')}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
@ -105,14 +112,31 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
</DialogDescription>
</DialogHeader>
{!hasTwoFactorAuthentication && (
<div className="mt-4">
<Label>
Please type{' '}
<span className="text-muted-foreground font-semibold">{user.email}</span> to
confirm.
</Label>
<Input
type="text"
className="mt-2"
aria-label="Confirm Email"
value={enteredEmail}
onChange={(e) => setEnteredEmail(e.target.value)}
/>
</div>
)}
<DialogFooter>
<Button
onClick={onDeleteAccount}
loading={isDeletingAccount}
variant="destructive"
disabled={hasTwoFactorAuthentication}
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
>
{isDeletingAccount ? 'Deleting account...' : 'Delete Account'}
{isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -25,7 +25,11 @@ type TemplateWithRecipient = Template & {
};
type TemplatesDataTableProps = {
templates: TemplateWithRecipient[];
templates: Array<
TemplateWithRecipient & {
team: { id: number; url: string } | null;
}
>;
perPage: number;
page: number;
totalPages: number;

View File

@ -1,23 +1,35 @@
'use client';
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { Template } from '@documenso/prisma/client';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Template } from '@documenso/prisma/client';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DataTableTitleProps = {
row: Template;
row: Template & {
team: { id: number; url: string } | null;
};
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const { data: session } = useSession();
const team = useOptionalCurrentTeam();
if (!session) {
return null;
}
const isCurrentTeamTemplate = team?.url && row.team?.url === team?.url;
const templatesPath = formatTemplatesPath(isCurrentTeamTemplate ? team?.url : undefined);
return (
<Link
href={`/templates/${row.id}`}
href={`${templatesPath}/${row.id}`}
className="block max-w-[10rem] cursor-pointer truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}

View File

@ -10,7 +10,7 @@ type UnauthenticatedLayoutProps = {
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div>
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<Image

View File

@ -34,7 +34,7 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
return (
<SignUpFormV2
className="w-screen max-w-screen-2xl px-4 md:px-16"
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>

View File

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

View File

@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<Button
data-testid="menu-switcher"
variant="none"
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus-visible:border-0 focus-visible:ring-0"
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
>
<AvatarWithText
avatarFallback={formatAvatarFallback(selectedTeam?.name)}

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@ -149,6 +149,13 @@ export const EnableAuthenticatorAppDialog = ({
}
};
useEffect(() => {
// Reset the form when the Dialog closes
if (!open) {
setupTwoFactorAuthenticationForm.reset();
}
}, [open, setupTwoFactorAuthenticationForm]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@ -92,6 +92,13 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
}
};
useEffect(() => {
// Reset the form when the Dialog closes
if (!open) {
viewRecoveryCodesForm.reset();
}
}, [open, viewRecoveryCodesForm]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">

View File

@ -154,7 +154,7 @@ export const ClaimPublicProfileDialogForm = ({
<FormMessage />
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm">
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>

View File

@ -108,17 +108,6 @@ export const SignUpFormV2 = ({
const name = form.watch('name');
const url = form.watch('url');
// To continue we need to make sure name, email, password and signature are valid
const canContinue =
form.formState.dirtyFields.name &&
form.formState.errors.name === undefined &&
form.formState.dirtyFields.email &&
form.formState.errors.email === undefined &&
form.formState.dirtyFields.password &&
form.formState.errors.password === undefined &&
form.formState.dirtyFields.signature &&
form.formState.errors.signature === undefined;
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
@ -169,6 +158,14 @@ export const SignUpFormV2 = ({
}
};
const onNextClick = async () => {
const valid = await form.trigger(['name', 'email', 'password', 'signature']);
if (valid) {
setStep('CLAIM_USERNAME');
}
};
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
@ -224,12 +221,12 @@ export const SignUpFormV2 = ({
</div>
</div>
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(800px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
{step === 'BASIC_DETAILS' && (
<div className="h-20">
<h1 className="text-2xl font-semibold">Create a new account</h1>
<h1 className="text-xl font-semibold md:text-2xl">Create a new account</h1>
<p className="text-muted-foreground mt-2 text-sm">
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</p>
@ -238,9 +235,9 @@ export const SignUpFormV2 = ({
{step === 'CLAIM_USERNAME' && (
<div className="h-20">
<h1 className="text-2xl font-semibold">Claim your username now</h1>
<h1 className="text-xl font-semibold md:text-2xl">Claim your username now</h1>
<p className="text-muted-foreground mt-2 text-sm">
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
You will get notified & be able to set up your documenso public profile when we launch
the feature.
</p>
@ -257,8 +254,8 @@ export const SignUpFormV2 = ({
{step === 'BASIC_DETAILS' && (
<fieldset
className={cn(
'flex h-[500px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[600px]',
'flex h-[550px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[650px]',
)}
disabled={isSubmitting}
>
@ -360,8 +357,8 @@ export const SignUpFormV2 = ({
{step === 'CLAIM_USERNAME' && (
<fieldset
className={cn(
'flex h-[500px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[600px]',
'flex h-[550px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[650px]',
)}
disabled={isSubmitting}
>
@ -378,7 +375,7 @@ export const SignUpFormV2 = ({
<FormMessage />
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block truncate rounded-md border px-2 py-1 text-sm lowercase">
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block max-w-[16rem] truncate rounded-md border px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
@ -431,9 +428,8 @@ export const SignUpFormV2 = ({
type="button"
size="lg"
className="flex-1 disabled:cursor-not-allowed"
disabled={!canContinue}
loading={form.formState.isSubmitting}
onClick={() => setStep('CLAIM_USERNAME')}
onClick={onNextClick}
>
Next
</Button>

View File

@ -24,9 +24,8 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-flex items-center rounded-md border px-2.5 py-1.5 text-sm">
<span>{baseUrl.host}/u/</span>
<span className="inline-block max-w-[8rem] truncate lowercase">{user.url}</span>
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm">
{baseUrl.host}/u/{user.url}
</div>
<div className="mt-4">

View File

@ -25,7 +25,7 @@ export const UserProfileTimur = ({ className, rows = 2 }: UserProfileTimurProps)
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block rounded-md border px-2.5 py-1.5 text-sm">
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm">
{baseUrl.host}/u/timur
</div>

View File

@ -29,7 +29,16 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
```
4. Run the following command to start the containers:
4. Update the volume binding for the cert file in the `compose.yml` file to point to your own key file:
Since the `cert.p12` file is required for signing and encrypting documents, you will need to provide your own key file. Update the volume binding in the `compose.yml` file to point to your key file:
```yaml
volumes:
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
```
1. Run the following command to start the containers:
```
docker-compose --env-file ./.env -d up
@ -60,18 +69,17 @@ docker pull ghcr.io/documenso/documenso
```
docker run -d \
-p 3000:3000 \
-e POSTGRES_USER="<your-postgres-user>"
-e POSTGRES_PASSWORD="<your-postgres-password>"
-e POSTGRES_DB="<your-postgres-db>"
-e NEXTAUTH_URL="<your-nextauth-url>"
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>"
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>"
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>"
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>"
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>"
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>"
-v /path/to/your/keyfile.p12:/opt/documenso/cert.p12
documenso/documenso
```
@ -101,6 +109,10 @@ Here's a markdown table documenting all the provided environment variables:
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |

View File

@ -57,8 +57,11 @@ services:
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?:-/opt/documenso/cert.p12}
ports:
- ${PORT:-3000}:${PORT:-3000}
volumes:
- /opt/documenso/cert.p12:/opt/documenso/cert.p12
volumes:
database:

View File

@ -47,5 +47,8 @@ services:
- NEXT_PRIVATE_SMTP_PASSWORD=password
- NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
- NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@documenso.com
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
ports:
- 3000:3000
volumes:
- ../../apps/web/example/cert.p12:/opt/documenso/cert.p12

View File

@ -1,7 +1,15 @@
const path = require('path');
const eslint = (filenames) =>
`eslint --fix ${filenames.map((f) => `"${path.relative(process.cwd(), f)}"`).join(' ')}`;
const prettier = (filenames) =>
`prettier --write ${filenames.map((f) => `"${path.relative(process.cwd(), f)}"`).join(' ')}`;
/** @type {import('lint-staged').Config} */
module.exports = {
'**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`),
'**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*.{ts,tsx,cts,mts}': [eslint, prettier],
'**/*.{js,jsx,cjs,mjs}': [prettier],
'**/*.{yml,mdx}': [prettier],
'**/*/package.json': 'npm run precommit',
};

4221
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@
"commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
"dx:up": "docker compose -f docker/development/compose.yml up -d",
"dx:down": "docker compose -f docker/development/compose.yml down",
"ci": "turbo run test:e2e",
@ -20,7 +20,7 @@
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
"prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma",
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
"prisma:studio": "npm run with:env -- npm run prisma:studio -w @documenso/prisma",
"with:env": "dotenv -e .env -e .env.local --",
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
"precommit": "npm install && git add package.json package-lock.json"
@ -36,8 +36,8 @@
"dotenv-cli": "^7.3.0",
"eslint": "^8.40.0",
"eslint-config-custom": "*",
"husky": "^8.0.0",
"lint-staged": "^14.0.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"prettier": "^2.5.1",
"rimraf": "^5.0.1",
"turbo": "^1.9.3"
@ -48,6 +48,7 @@
"packages/*"
],
"dependencies": {
"@documenso/pdf-sign": "^0.1.0",
"next-runtime-env": "^3.2.0"
},
"overrides": {

View File

@ -1,11 +1,30 @@
'use client';
import { useEffect } from 'react';
import { useTheme } from 'next-themes';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
const { resolvedTheme } = useTheme();
useEffect(() => {
const body = document.body;
if (resolvedTheme === 'dark') {
body.classList.add('swagger-dark-theme');
} else {
body.classList.remove('swagger-dark-theme');
}
return () => {
body.classList.remove('swagger-dark-theme');
};
}, [resolvedTheme]);
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
};

View File

@ -0,0 +1,27 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users';
import { manualLogin } from './fixtures/authentication';
test('delete user', async ({ page }) => {
const user = await seedUser();
await manualLogin({
page,
email: user.email,
redirectPath: '/settings',
});
await page.getByRole('button', { name: 'Delete Account' }).click();
await page.getByLabel('Confirm Email').fill(user.email);
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
// Verify that the user no longer exists in the database
await expect(getUserByEmail({ email: user.email })).rejects.toThrow();
});

View File

@ -4,23 +4,22 @@ module.exports = {
'turbo',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:package-json/recommended',
],
plugins: ['prettier', 'package-json', 'unused-imports'],
plugins: ['package-json', 'unused-imports'],
env: {
es2022: true,
node: true,
browser: true,
es6: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['../../apps/*/tsconfig.json', '../../packages/*/tsconfig.json'],
project: ['../../tsconfig.eslint.json'],
ecmaVersion: 2022,
ecmaFeatures: {
jsx: true,

View File

@ -7,16 +7,14 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0",
"eslint": "^8.40.0",
"eslint-config-next": "13.4.19",
"eslint-config-prettier": "^8.8.0",
"eslint-config-turbo": "^1.9.3",
"eslint-plugin-package-json": "^0.2.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-unused-imports": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.3",
"eslint-config-turbo": "^1.12.5",
"eslint-plugin-package-json": "^0.10.4",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-unused-imports": "^3.1.0",
"typescript": "5.2.2"
}
}

View File

@ -1,2 +1,2 @@
export const URL_REGEX =
/^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;
/^(https?):\/\/(?:www\.)?(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;

View File

@ -24,6 +24,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
include: {
documentData: true,
Recipient: true,
User: true,
team: {
select: {
id: true,
url: true,
},
},
},
});
@ -35,61 +42,116 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document has no recipients');
}
const buffer = await getFile(document.documentData);
const { User: owner } = document;
const completedDocument = await getFile(document.documentData);
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/documents/${document.id}`;
if (document.team?.url) {
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${document.team.url}/documents/${
document.id
}`;
}
// If the document owner is not a recipient then send the email to them separately
if (!document.Recipient.find((recipient) => recipient.email === owner.email)) {
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,
assetBaseUrl,
downloadLink: documentOwnerDownloadLink,
});
await mailer.sendMail({
to: [
{
name: owner.name || '',
address: owner.email,
},
],
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(completedDocument),
},
],
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
requestMetadata,
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: owner.email,
recipientName: owner.name,
recipientId: owner.id,
recipientRole: 'OWNER',
isResending: false,
},
}),
});
}
await Promise.all(
document.Recipient.map(async (recipient) => {
const { email, name, token } = recipient;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,
assetBaseUrl,
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
downloadLink: recipient.email === owner.email ? documentOwnerDownloadLink : downloadLink,
});
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
},
],
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
requestMetadata,
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
isResending: false,
},
}),
});
await mailer.sendMail({
to: [
{
name: recipient.name,
address: recipient.email,
},
],
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
{ timeout: 30_000 },
);
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(completedDocument),
},
],
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
requestMetadata,
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
isResending: false,
},
}),
});
}),
);
};

View File

@ -37,6 +37,12 @@ export const findTemplates = async ({
where: whereFilter,
include: {
templateDocumentData: true,
team: {
select: {
id: true,
url: true,
},
},
Field: true,
Recipient: true,
},

View File

@ -0,0 +1,35 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
export type GetCompletedDocumentsMonthlyResult = Array<{
month: string;
count: number;
cume_count: number;
}>;
type GetCompletedDocumentsMonthlyQueryResult = Array<{
month: Date;
count: bigint;
cume_count: bigint;
}>;
export const getCompletedDocumentsMonthly = async () => {
const result = await prisma.$queryRaw<GetCompletedDocumentsMonthlyQueryResult>`
SELECT
DATE_TRUNC('month', "updatedAt") AS "month",
COUNT("id") as "count",
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "updatedAt")) as "cume_count"
FROM "Document"
WHERE "status" = 'COMPLETED'
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
cume_count: Number(row.cume_count),
}));
};

View File

@ -10,9 +10,10 @@
"clean": "rimraf node_modules",
"post-install": "prisma generate",
"prisma:generate": "prisma generate",
"prisma:migrate-dev": "prisma migrate dev",
"prisma:migrate-dev": "prisma migrate dev --skip-seed",
"prisma:migrate-deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed"
"prisma:seed": "prisma db seed",
"prisma:studio": "prisma studio"
},
"prisma": {
"seed": "ts-node --transpileOnly --project ./tsconfig.seed.json ./seed-database.ts"

View File

@ -182,6 +182,7 @@ const createCompletedDocument = async (sender: User, recipients: User[]) => {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`,
status: DocumentStatus.COMPLETED,
documentDataId: documentData.id,
completedAt: new Date(),
userId: sender.id,
},
});

View File

@ -0,0 +1,3 @@
// We use stars as a placeholder since it's easy to find and replace,
// the length of the placeholder is to support larger pdf files
export const BYTE_RANGE_PLACEHOLDER = '**********';

View File

@ -1,4 +1,3 @@
import signer from 'node-signpdf';
import {
PDFArray,
PDFDocument,
@ -9,6 +8,8 @@ import {
rectangle,
} from 'pdf-lib';
import { BYTE_RANGE_PLACEHOLDER } from '../constants/byte-range';
export type AddSigningPlaceholderOptions = {
pdf: Buffer;
};
@ -20,9 +21,9 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
const byteRange = PDFArray.withContext(doc.context);
byteRange.push(PDFNumber.of(0));
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
const signature = doc.context.obj({
Type: 'Sig',

View File

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { updateSigningPlaceholder } from './update-signing-placeholder';
describe('updateSigningPlaceholder', () => {
const pdf = Buffer.from(`
20 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
`);
it('should not throw an error', () => {
expect(() => updateSigningPlaceholder({ pdf })).not.toThrowError();
});
it('should not modify the original PDF', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.pdf).not.toEqual(pdf);
});
it('should return a PDF with the same length as the original', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.pdf).toHaveLength(pdf.length);
});
it('should update the byte range and return it', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.byteRange).toEqual([0, 184, 241, 92]);
});
it('should only update the last signature in the PDF', () => {
const pdf = Buffer.from(`
20 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
21 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
`);
const result = updateSigningPlaceholder({ pdf });
expect(result.byteRange).toEqual([0, 512, 569, 92]);
});
});

View File

@ -0,0 +1,39 @@
export type UpdateSigningPlaceholderOptions = {
pdf: Buffer;
};
export const updateSigningPlaceholder = ({ pdf }: UpdateSigningPlaceholderOptions) => {
const length = pdf.length;
const byteRangePos = pdf.lastIndexOf('/ByteRange');
const byteRangeStart = pdf.indexOf('[', byteRangePos);
const byteRangeEnd = pdf.indexOf(']', byteRangePos);
const byteRangeSlice = pdf.subarray(byteRangeStart, byteRangeEnd + 1);
const signaturePos = pdf.indexOf('/Contents', byteRangeEnd);
const signatureStart = pdf.indexOf('<', signaturePos);
const signatureEnd = pdf.indexOf('>', signaturePos);
const signatureSlice = pdf.subarray(signatureStart, signatureEnd + 1);
const byteRange = [0, 0, 0, 0];
byteRange[1] = signatureStart;
byteRange[2] = byteRange[1] + signatureSlice.length;
byteRange[3] = length - byteRange[2];
const newByteRange = `[${byteRange.join(' ')}]`.padEnd(byteRangeSlice.length, ' ');
const updatedPdf = Buffer.concat([
pdf.subarray(0, byteRangeStart),
Buffer.from(newByteRange),
pdf.subarray(byteRangeEnd + 1),
]);
if (updatedPdf.length !== length) {
throw new Error('Updated PDF length does not match original length');
}
return { pdf: updatedPdf, byteRange };
};

View File

@ -1,5 +1,6 @@
import { match } from 'ts-pattern';
import { signWithGoogleCloudHSM } from './transports/google-cloud-hsm';
import { signWithLocalCert } from './transports/local-cert';
export type SignOptions = {
@ -11,6 +12,7 @@ export const signPdf = async ({ pdf }: SignOptions) => {
return await match(transport)
.with('local', async () => signWithLocalCert({ pdf }))
.with('gcloud-hsm', async () => signWithGoogleCloudHSM({ pdf }))
.otherwise(() => {
throw new Error(`Unsupported signing transport: ${transport}`);
});

View File

@ -9,15 +9,15 @@
"index.ts"
],
"scripts": {
"test": "vitest"
},
"dependencies": {
"@documenso/tsconfig": "*",
"node-forge": "^1.3.1",
"node-signpdf": "^2.0.0",
"@documenso/pdf-sign": "^0.1.0",
"pdf-lib": "^1.17.1",
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"@types/node-forge": "^1.3.4"
"vitest": "^1.3.1"
}
}

View File

@ -0,0 +1,79 @@
import fs from 'node:fs';
import { signWithGCloud } from '@documenso/pdf-sign';
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
export type SignWithGoogleCloudHSMOptions = {
pdf: Buffer;
};
export const signWithGoogleCloudHSM = async ({ pdf }: SignWithGoogleCloudHSMOptions) => {
const keyPath = process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH;
if (!keyPath) {
throw new Error('No certificate path provided for Google Cloud HSM signing');
}
// To handle hosting in serverless environments like Vercel we can supply the base64 encoded
// application credentials as an environment variable and write it to a file if it doesn't exist
if (
process.env.GOOGLE_APPLICATION_CREDENTIALS &&
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS
) {
if (!fs.existsSync(process.env.GOOGLE_APPLICATION_CREDENTIALS)) {
fs.writeFileSync(
process.env.GOOGLE_APPLICATION_CREDENTIALS,
Buffer.from(
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS,
'base64',
),
);
}
}
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
pdf: await addSigningPlaceholder({ pdf }),
});
const pdfWithoutSignature = Buffer.concat([
pdfWithPlaceholder.subarray(0, byteRange[1]),
pdfWithPlaceholder.subarray(byteRange[2]),
]);
const signatureLength = byteRange[2] - byteRange[1];
let cert: Buffer | null = null;
if (process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS) {
cert = Buffer.from(
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS,
'base64',
);
}
if (!cert) {
cert = Buffer.from(
fs.readFileSync(
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH || './example/cert.crt',
),
);
}
const signature = signWithGCloud({
keyPath,
cert,
content: pdfWithoutSignature,
});
const signatureAsHex = signature.toString('hex');
const signedPdf = Buffer.concat([
pdfWithPlaceholder.subarray(0, byteRange[1]),
Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`),
pdfWithPlaceholder.subarray(byteRange[2]),
]);
return signedPdf;
};

View File

@ -1,32 +1,51 @@
import signer from 'node-signpdf';
import fs from 'node:fs';
import { addSigningPlaceholder } from '../helpers/addSigningPlaceholder';
import { signWithP12 } from '@documenso/pdf-sign';
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
export type SignWithLocalCertOptions = {
pdf: Buffer;
};
export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
const pdfWithPlaceholder = await addSigningPlaceholder({ pdf });
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
pdf: await addSigningPlaceholder({ pdf }),
});
let p12Cert: Buffer | null = null;
const pdfWithoutSignature = Buffer.concat([
pdfWithPlaceholder.subarray(0, byteRange[1]),
pdfWithPlaceholder.subarray(byteRange[2]),
]);
const signatureLength = byteRange[2] - byteRange[1];
let cert: Buffer | null = null;
if (process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS) {
p12Cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64');
cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64');
}
if (!p12Cert) {
p12Cert = Buffer.from(
if (!cert) {
cert = Buffer.from(
fs.readFileSync(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH || './example/cert.p12'),
);
}
if (process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE) {
return signer.sign(pdfWithPlaceholder, p12Cert, {
passphrase: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE,
});
}
const signature = signWithP12({
cert,
content: pdfWithoutSignature,
password: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE || undefined,
});
return signer.sign(pdfWithPlaceholder, p12Cert);
const signatureAsHex = signature.toString('hex');
const signedPdf = Buffer.concat([
pdfWithPlaceholder.subarray(0, byteRange[1]),
Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`),
pdfWithPlaceholder.subarray(byteRange[2]),
]);
return signedPdf;
};

View File

@ -28,6 +28,9 @@ module.exports = {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',

View File

@ -30,6 +30,10 @@ declare namespace NodeJS {
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS?: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api';

View File

@ -0,0 +1,120 @@
import type { Variants } from 'framer-motion';
export const DocumentDropzoneContainerVariants: Variants = {
initial: {
scale: 1,
},
animate: {
scale: 1,
},
hover: {
transition: {
staggerChildren: 0.05,
},
},
};
export const DocumentDropzoneCardLeftVariants: Variants = {
initial: {
x: 40,
y: -10,
rotate: -14,
},
animate: {
x: 40,
y: -10,
rotate: -14,
},
hover: {
x: -25,
y: -25,
rotate: -22,
},
};
export const DocumentDropzoneCardRightVariants: Variants = {
initial: {
x: -40,
y: -10,
rotate: 14,
},
animate: {
x: -40,
y: -10,
rotate: 14,
},
hover: {
x: 25,
y: -25,
rotate: 22,
},
};
export const DocumentDropzoneCardCenterVariants: Variants = {
initial: {
x: 0,
y: 0,
},
animate: {
x: 0,
y: 0,
},
hover: {
x: 0,
y: -25,
},
};
export const DocumentDropzoneDisabledCardLeftVariants: Variants = {
initial: {
x: 50,
y: 0,
rotate: -14,
},
animate: {
x: 50,
y: 0,
rotate: -14,
},
hover: {
x: 30,
y: 0,
rotate: -17,
transition: { type: 'spring', duration: 0.3, stiffness: 500 },
},
};
export const DocumentDropzoneDisabledCardRightVariants: Variants = {
initial: {
x: -50,
y: 0,
rotate: 14,
},
animate: {
x: -50,
y: 0,
rotate: 14,
},
hover: {
x: -30,
y: 0,
rotate: 17,
transition: { type: 'spring', duration: 0.3, stiffness: 500 },
},
};
export const DocumentDropzoneDisabledCardCenterVariants: Variants = {
initial: {
x: -10,
y: 0,
},
animate: {
x: -10,
y: 0,
},
hover: {
x: [-15, -10, -5, -10],
y: 0,
transition: { type: 'spring', duration: 0.3, stiffness: 1000 },
},
};

View File

@ -65,7 +65,7 @@ export function DataTablePagination<TData>({
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-4 lg:gap-x-8">
<div className="flex items-center text-sm font-medium md:justify-center">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount() || 1}
</div>
<div className="flex items-center gap-x-2">

View File

@ -115,7 +115,12 @@ export function DataTable<TData, TValue>({
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<TableCell
key={cell.id}
style={{
width: `${cell.column.getSize()}px`,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}

View File

@ -1,90 +1,27 @@
'use client';
import type { Variants } from 'framer-motion';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Plus } from 'lucide-react';
import { AlertTriangle, Plus } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import {
DocumentDropzoneCardCenterVariants,
DocumentDropzoneCardLeftVariants,
DocumentDropzoneCardRightVariants,
DocumentDropzoneContainerVariants,
DocumentDropzoneDisabledCardCenterVariants,
DocumentDropzoneDisabledCardLeftVariants,
DocumentDropzoneDisabledCardRightVariants,
} from '../lib/document-dropzone-constants';
import { cn } from '../lib/utils';
import { Button } from './button';
import { Card, CardContent } from './card';
const DocumentDropzoneContainerVariants: Variants = {
initial: {
scale: 1,
},
animate: {
scale: 1,
},
hover: {
transition: {
staggerChildren: 0.05,
},
},
};
const DocumentDropzoneCardLeftVariants: Variants = {
initial: {
x: 40,
y: -10,
rotate: -14,
},
animate: {
x: 40,
y: -10,
rotate: -14,
},
hover: {
x: -25,
y: -25,
rotate: -22,
},
};
const DocumentDropzoneCardRightVariants: Variants = {
initial: {
x: -40,
y: -10,
rotate: 14,
},
animate: {
x: -40,
y: -10,
rotate: 14,
},
hover: {
x: 25,
y: -25,
rotate: 22,
},
};
const DocumentDropzoneCardCenterVariants: Variants = {
initial: {
x: 0,
y: 0,
},
animate: {
x: 0,
y: 0,
},
hover: {
x: 0,
y: -25,
},
};
const DocumentDescription = {
document: {
headline: 'Add a document',
},
template: {
headline: 'Upload Template Document',
},
};
export type DocumentDropzoneProps = {
className?: string;
disabled?: boolean;
@ -123,68 +60,108 @@ export const DocumentDropzone = ({
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
});
const heading = {
document: disabled ? 'You have reached your document limit.' : 'Add a document',
template: 'Upload Template Document',
};
return (
<motion.div
className={cn('flex aria-disabled:cursor-not-allowed', className)}
variants={DocumentDropzoneContainerVariants}
initial="initial"
animate="animate"
whileHover="hover"
aria-disabled={disabled}
>
<Card
role="button"
className={cn(
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 aria-disabled:pointer-events-none aria-disabled:opacity-60',
'focus-visible:ring-ring ring-offset-background group flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
className,
)}
gradient={true}
gradient={!disabled}
degrees={120}
aria-disabled={disabled}
{...getRootProps()}
{...props}
>
<CardContent className="text-muted-foreground/40 flex flex-col items-center justify-center p-6">
{/* <FilePlus strokeWidth="1px" className="h-16 w-16"/> */}
<div className="flex">
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={!disabled ? DocumentDropzoneCardLeftVariants : undefined}
>
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</motion.div>
{disabled ? (
// Disabled State
<div className="flex">
<motion.div
className="group-hover:bg-destructive/2 border-muted-foreground/20 group-hover:border-destructive/10 dark:bg-muted/80 a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneDisabledCardLeftVariants}
>
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-full rounded-[2px]" />
</motion.div>
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={!disabled ? DocumentDropzoneCardCenterVariants : undefined}
>
<Plus
strokeWidth="2px"
className="text-muted-foreground/20 group-hover:text-documenso h-12 w-12"
/>
</motion.div>
<motion.div
className="group-hover:bg-destructive/5 border-muted-foreground/20 group-hover:border-destructive/50 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneDisabledCardCenterVariants}
>
<AlertTriangle
strokeWidth="2px"
className="text-muted-foreground/20 group-hover:text-destructive h-12 w-12"
/>
</motion.div>
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={!disabled ? DocumentDropzoneCardRightVariants : undefined}
>
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</motion.div>
</div>
<motion.div
className="group-hover:bg-destructive/2 border-muted-foreground/20 group-hover:border-destructive/10 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneDisabledCardRightVariants}
>
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-full rounded-[2px]" />
</motion.div>
</div>
) : (
// Non Disabled State
<div className="flex">
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneCardLeftVariants}
>
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</motion.div>
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneCardCenterVariants}
>
<Plus
strokeWidth="2px"
className="text-muted-foreground/20 group-hover:text-documenso h-12 w-12"
/>
</motion.div>
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneCardRightVariants}
>
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</motion.div>
</div>
)}
<input {...getInputProps()} />
<p className="group-hover:text-foreground text-muted-foreground mt-8 font-medium">
{DocumentDescription[type].headline}
</p>
<p className="text-foreground mt-8 font-medium">{heading[type]}</p>
<p className="text-muted-foreground/80 mt-1 text-sm">
<p className="text-muted-foreground/80 mt-1 text-center text-sm">
{disabled ? disabledMessage : 'Drag & drop your PDF here.'}
</p>
{disabled && IS_BILLING_ENABLED() && (
<Button className="hover:bg-warning/80 bg-warning mt-4 w-32" asChild>
<Link href="/settings/billing">Upgrade</Link>
</Button>
)}
</CardContent>
</Card>
</motion.div>

View File

@ -151,9 +151,10 @@ export const AddSignersFormPartial = ({
<AnimatePresence>
{signers.map((signer, index) => (
<motion.div
<motion.fieldset
key={signer.id}
data-native-id={signer.nativeId}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
@ -202,7 +203,11 @@ export const AddSignersFormPartial = ({
control={control}
name={`signers.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<Select
value={value}
onValueChange={(x) => onChange(x)}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent className="" align="end">
@ -258,7 +263,7 @@ export const AddSignersFormPartial = ({
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
</div>
</motion.div>
</motion.fieldset>
))}
</AnimatePresence>
</div>

View File

@ -1,3 +1,5 @@
'use client';
import { motion } from 'framer-motion';
import { Monitor, MoonStar, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';

View File

@ -79,7 +79,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
className={cn(
'text-foreground/50 hover:text-foreground absolute right-2 top-2 rounded-md p-1 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
'text-foreground/50 hover:text-foreground absolute right-2 top-2 rounded-md p-1 opacity-100 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 md:opacity-0 group-hover:md:opacity-100',
className,
)}
toast-close=""

View File

@ -42,6 +42,8 @@
--ring: 95.08 71.08% 67.45%;
--radius: 0.5rem;
--warning: 54 96% 45%;
}
.dark {
@ -79,6 +81,8 @@
--ring: 95.08 71.08% 67.45%;
--radius: 0.5rem;
--warning: 54 96% 45%;
}
}
@ -87,6 +91,11 @@
@apply border-border;
}
html,
body {
scrollbar-gutter: stable;
}
body {
@apply bg-background text-foreground;
font-feature-settings: 'rlig' 1, 'calt' 1;
@ -120,3 +129,12 @@
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgb(100 116 139 / 0.5);
}
/* Custom Swagger Dark Theme */
.swagger-dark-theme .swagger-ui {
filter: invert(88%) hue-rotate(180deg);
}
.swagger-dark-theme .swagger-ui .microlight {
filter: invert(100%) hue-rotate(180deg);
}

4
tsconfig.eslint.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./packages/tsconfig/base.json",
"include": ["packages/**/*", "apps/**/*"]
}

View File

@ -48,6 +48,14 @@
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
"NEXT_PRIVATE_SIGNING_TRANSPORT",
"NEXT_PRIVATE_SIGNING_PASSPHRASE",
"NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH",
"NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
@ -93,6 +101,7 @@
"DATABASE_URL_UNPOOLED",
"POSTGRES_PRISMA_URL",
"POSTGRES_URL_NON_POOLING",
"GOOGLE_APPLICATION_CREDENTIALS",
"E2E_TEST_AUTHENTICATE_USERNAME",
"E2E_TEST_AUTHENTICATE_USER_EMAIL",
"E2E_TEST_AUTHENTICATE_USER_PASSWORD"