Compare commits

...

166 Commits

Author SHA1 Message Date
c2cf25b138 fix: templates incorrectly linking when in a team 2024-03-11 01:46:19 +00: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
3b5f8d149a fix: eslint config file parseOptions.project path is updated 2024-03-08 21:14:37 +05:30
4476cf8fd1 Merge branch 'main' into fix/layout-shift-on-table 2024-03-09 00:41:20 +11: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
fd4ea3aca5 fix: update docker docs 2024-03-08 20:00:24 +11: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
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
2ccc2f22de chore: back to docker buildx 2024-03-07 10:24:17 +00:00
f6eddaa9f6 fix: remove duplicate neon pooler (#990)
## Description

Fixes the issue with Vercel preview deployments failing.

It appears that the old `PGHOST` environment variable injected by neon
was:

`ep-snowy-snowflake-a2vc5pa2.eu-central-1.aws.neon.tech`

It is now:

`ep-snowy-snowflake-a2vc5pa2.eu-central-1-pooler.aws.neon.tech`

Notice the `-pooler` being attached automatically to the `PGHOST`.

## References

> The following changes were made to the [Neon Vercel
Integration](https://vercel.com/integrations/neon):
>
>To ensure that users accessing a Neon database from a serverless
environment have enough connections, the DATABASE_URL and PGHOST
environment variables added to a Vercel project by the Neon integration
are now set to a pooled Neon connection string by default. Pooled
connections support up to 10,000 simultaneous connections. Previously,
these variables were set to an unpooled connection string supporting
fewer concurrent connections.

https://neon.tech/docs/changelog

https://neon.tech/docs/guides/vercel#manage-vercel-environment-variables
2024-03-07 18:17:28 +08:00
35b2405921 chore: use warpbuild for docker publishing 2024-03-07 10:02:33 +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
0c8b24ba75 chore: remove bcrypt (#993)
Remove `bcrypt` in favor of `@node-rs/bcrypt` which includes precompiled
binaries for many platforms reducing the number of tasks to run post
`npm install`.

Resolves #986
2024-03-07 19:58:53 +11:00
8dad3607cf fix: add @node-rs/bcrypt to server component externals 2024-03-07 18:48:06 +11:00
cffb7907b5 chore: remove bcrypt 2024-03-07 18:30:22 +11:00
b9b57f16c0 fix: use matrix build 2024-03-07 05:05:35 +00:00
03d2a2a278 fix: update buildx platform arg 2024-03-07 04:35:25 +00:00
82efc5f589 fix: use docker buildx and push 2024-03-07 04:07:55 +00:00
8a9588ca65 fix: fetch tags for publish action 2024-03-07 03:49:03 +00:00
f723a34464 chore: remove launch week quote 2024-03-07 14:12:10 +11:00
dd1d657057 feat: tidy docker setup (#988)
Tidy the docker setup and include a Github Action
for publishing docker containers to DockerHub and
Github Container Registry.

Also add a small README file with docker hosting instructions.
2024-03-07 14:11:03 +11:00
10ef5b6e51 fix: improvements from testing 2024-03-07 02:57:02 +00:00
b5b74a788c fix: overflow issue with user name input 2024-03-06 14:52:59 +00:00
9525b9bd63 fix: resolve issue with testing compose file 2024-03-06 23:48:28 +11:00
d382e03085 feat: add typefully card to open page (#979)
**Description:**

The new card for typefully looks like the below screenshot

<img width="960" alt="Screenshot 2024-03-01 at 12 53 00"
src="https://github.com/documenso/documenso/assets/23498248/925d3362-f883-48b2-8870-83b8115bac7d">
2024-03-06 13:30:35 +05:30
b8fa45380b Merge branch 'main' into feat/typefully 2024-03-06 13:28:52 +05:30
2cce6dc2e5 feat: tidy docker setup
Tidy the docker setup and include a Github Action
for publishing docker containers to DockerHub and
Github Container Registry.

Also add a small README file with docker hosting instructions.
2024-03-06 15:46:51 +11:00
927cb1a934 fix: incorrect download filename logic 2024-03-05 23:05:32 +00:00
579a2f96e5 chore: rename community => early adopter 2024-03-05 13:04:46 +00:00
8a82952b34 Merge branch 'main' into feat/typefully 2024-03-05 16:42:29 +05:30
70494fa5bb feat: add offline development support (#987)
## Description

Add support to develop without network access since TRPC by default will
prevent network requests when offline.

https://tanstack.com/query/v4/docs/framework/react/guides/network-mode#network-mode-always

## Changes Made

- Add dynamic logic to toggle offline development
- Removed teams feature flag
2024-03-05 22:06:48 +11:00
41ccefe212 chore: remove twt logo asset
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-05 15:43:33 +05:30
e83a4becee chore: update twitter logo
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-05 15:41:34 +05:30
a03ce728f3 chore: remove ssr
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-05 15:34:42 +05:30
d29caaf823 Merge branch 'feat/typefully' of https://github.com/documenso/documenso into feat/typefully 2024-03-05 14:59:18 +05:30
7e9b96f059 Merge branch 'main' of https://github.com/documenso/documenso into feat/typefully 2024-03-05 14:58:27 +05:30
691255da3f chore: updated styling of tooltips and margins
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-05 14:57:29 +05:30
f6f9c301da feat(ci): cache github workflow actions (#804) 2024-03-05 10:04:06 +11:00
a17d4a2a39 fix: handle signature annotations 2024-03-03 11:36:28 +11:00
73aae6f1e3 feat: improve admin panel 2024-03-03 01:55:33 +11:00
328d16483c chore: update profile claim dialog and modal (#983)
**Description:**

**Settings Page:**

<img width="668" alt="Screenshot 2024-03-01 at 19 12 40"
src="https://github.com/documenso/documenso/assets/23498248/08e48432-39a6-4ef0-bc53-931fc3c81545">

**Claim Modal:** 

<img width="588" alt="Screenshot 2024-03-01 at 19 14 17"
src="https://github.com/documenso/documenso/assets/23498248/69bc2d02-97c6-4a29-88a4-55ed8898ccf5">
2024-03-02 12:45:22 +11:00
6dd2abfe51 fix: username min length + fixed condition (#982)
fixed #981 

`url.length <= 6` >>> `url.length < 6`

also removed debug message from the form component
2024-03-02 12:43:44 +11:00
452545dab1 chore: updated button text
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-01 19:12:09 +05:30
437410c73a chore: updated text color
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-01 19:11:47 +05:30
36a95f6153 chore: updated text color
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-01 19:10:15 +05:30
0f03ad4a6b chore: updated wordings for claimed ursers
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-01 19:05:16 +05:30
8674ad4c88 docs: add node version minimum requirement (#975)
## PR fixes which issue ? 

This PR fixes #974 

## Description: 

- Have added minimum node version required to setup the Documenso
project locally which will ensure to save time of new contributors
setting up their project. 💚

## Reference for the minimum requirement decision: 


https://nextjs.org/docs/app/building-your-application/upgrading/version-14#v14-summary

---------

Co-authored-by: Adithya Krishna <aadithya794@gmail.com>
Co-authored-by: Catalin Pit <25515812+catalinpit@users.noreply.github.com>
2024-03-01 15:12:08 +02:00
98e00739c8 Merge branch 'main' into feat/typefully 2024-03-01 15:05:14 +02:00
00c36782ff fix: why didn't prettier catch this 2024-03-01 22:59:52 +11:00
665ccd7628 update username min characters 2024-03-01 11:30:42 +00:00
e5fe3d897d remove fixed true condition
from auth signup router
2024-03-01 11:27:24 +00:00
bfb1c65f98 remove debug logs
console.log on signup form
2024-03-01 11:25:21 +00:00
3b8b87a90b Chore/blog (#980)
day 5
2024-03-01 11:14:26 +01:00
819e58dd61 chore: image 2024-03-01 11:11:52 +01:00
8c435d48b7 chore: day5 2024-03-01 10:22:10 +01:00
0c8a89a2ea feat: add typefully card
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-03-01 12:53:51 +05:30
85885b5ad5 fix: api documentation errors on ssr 2024-03-01 14:54:41 +11:00
5cc4204788 fix: add early return to webhook trigger 2024-03-01 14:12:06 +11:00
e226f012a9 chore: ctas (#978) 2024-02-29 14:25:53 +01:00
4ad722ba00 chore: ctas 2024-02-29 14:25:21 +01:00
1927cc4067 chore: typos and links (#977) 2024-02-29 14:15:18 +01:00
c2ec092404 chore: typos and links 2024-02-29 14:14:35 +01:00
ebe23335f8 fix: return the recipient as an array to match other formats from zapier (#971)
Return the recipient as an array to match the other formats for Zapier.
Otherwise, Zaps with the "DOCUMENT_OPENED" hooks won't work.

All the other webhooks return the "Recipient" field as an array.
2024-02-29 08:37:01 +02:00
e5be219b99 feat: claim profile (#972)
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-29 15:29:47 +11:00
aa87a86a5f fix: update e2e tests 2024-02-29 15:19:38 +11:00
1cc7c95a33 fix: update e2e test 2024-02-29 14:26:37 +11:00
9f576eb47c fix: update signup mutation schema 2024-02-29 14:13:37 +11:00
bd82ad7a0b fix: revert settings-header changes 2024-02-29 14:12:00 +11:00
cc1b888174 chore: tidy changes 2024-02-29 14:09:17 +11:00
5576cdc2b0 Merge branch 'main' into feat/public-profile-1 2024-02-29 14:08:19 +11:00
ecc9dc63ea feat: the rest of the owl 2024-02-29 13:22:21 +11:00
11fe380338 chore: typo (#970) 2024-02-28 13:38:47 +01:00
f376c7b951 chore: typo 2024-02-28 13:38:10 +01:00
90477dfd00 chore: cta (#969) 2024-02-28 12:23:52 +01:00
dd81f946b4 chore: cta 2024-02-28 12:23:21 +01:00
f4e1df7f3a Chore/blog (#968) 2024-02-28 12:14:46 +01:00
3510d8b6b0 chore: text 2024-02-28 12:14:19 +01:00
1590fa9457 chore: text 2024-02-28 12:13:48 +01:00
ce09c32bf5 Chore/blog (#967)
day 3 lgtm
2024-02-28 11:59:56 +01:00
e3e2cfbcfd fix: refactor and implement design 2024-02-28 14:43:09 +11:00
8ab77e0e55 chore: day 3 2024-02-27 19:30:01 +01:00
837ff531a9 chore: format 2024-02-27 11:55:10 +01:00
b225cc8139 chore: updated styles
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-27 20:40:43 +11:00
b498f8edb7 feat: update ui
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-27 20:40:42 +11:00
b1261510d2 feat: update signin signup pages
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-27 20:39:20 +11:00
65d762dd4b feat: update signin signup ui
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-27 20:39:19 +11:00
deea6b1535 feat: update marketing site
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-27 20:36:40 +11: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
1d4e78e579 Merge branch 'main' into update-documents-avatar 2024-01-09 20:33:31 +08:00
6065140715 feat: cache layers 2024-01-06 15:29:19 +05:30
34a59d2db3 fix: cache 2024-01-06 15:18:33 +05:30
ba37633ecd fix: revert 2024-01-06 15:18:09 +05:30
46e83d65bb feat: cache docker 2024-01-06 15:14:28 +05:30
142c93aa63 chore: revert force build error 2024-01-06 14:45:44 +05:30
3eb1a17d3c chore: force build error 2024-01-06 14:42:49 +05:30
6d1ad179d4 feat: add clean cache workflow 2024-01-06 13:30:21 +05:30
8eed13e275 fix: remove additional workflow 2024-01-05 02:22:42 +05:30
346078dbbe fix: e2e 2024-01-05 02:15:27 +05:30
c8337d7dcc fix: key 2024-01-05 02:13:42 +05:30
75630ef19d fix: npm action 2024-01-05 01:58:00 +05:30
634807328e fix: command 2024-01-05 01:38:14 +05:30
2bbbe1098a fix: action 2024-01-05 01:32:47 +05:30
d24b9de254 fix: skip install 2024-01-05 01:19:22 +05:30
c86f79dd7b feat: add workflow call actions 2024-01-05 01:11:28 +05:30
0c12e34c38 fix: remove call 2024-01-05 01:08:32 +05:30
26b604dbd0 fix: add workflow call 2024-01-05 00:21:05 +05:30
308f55f3d4 fix: key 2024-01-05 00:09:41 +05:30
e5b7bf81fa fix: add action to codeql 2024-01-05 00:06:16 +05:30
b35f050409 fix: add shell 2024-01-05 00:03:20 +05:30
ce6f523230 fix: key 2024-01-04 23:56:32 +05:30
9e57de512a feat: use actions 2024-01-04 23:46:09 +05:30
9b5d64cc1a feat: add playwright action 2024-01-04 23:44:27 +05:30
fc372d0aa9 feat: add node install action 2024-01-04 23:41:48 +05:30
e470020b16 feat: add cache build action 2024-01-04 23:41:24 +05:30
0a9006430f fix: command 2024-01-04 23:40:35 +05:30
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
163 changed files with 4252 additions and 1201 deletions

View File

@ -27,6 +27,16 @@ E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[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
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"

24
.github/actions/cache-build/action.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: Cache production build binaries
description: 'Cache or restore if necessary'
inputs:
node_version:
required: false
default: v18.x
runs:
using: 'composite'
steps:
- name: Cache production build
uses: actions/cache@v3
id: production-build-cache
with:
path: |
${{ github.workspace }}/apps/web/.next
${{ github.workspace }}/apps/marketing/.next
**/.turbo/**
**/dist/**
key: prod-build-${{ github.run_id }}
restore-keys: prod-build-
- run: npm run build
shell: bash

39
.github/actions/node-install/action.yml vendored Normal file
View File

@ -0,0 +1,39 @@
name: 'Setup node and cache node_modules'
inputs:
node_version:
required: false
default: v18.x
runs:
using: 'composite'
steps:
- name: Set up Node ${{ inputs.node_version }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Cache node_modules
uses: actions/cache@v3
id: cache-node-modules
with:
path: |
node_modules
packages/*/node_modules
apps/*/node_modules
key: modules-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
shell: bash
run: |
npm ci --no-audit
npm run prisma:generate
env:
HUSKY: '0'

View File

@ -0,0 +1,19 @@
name: Install playwright binaries
description: 'Install playwright, cache and restore if necessary'
runs:
using: 'composite'
steps:
- name: Cache playwright
id: cache-playwright
uses: actions/cache@v3
with:
path: |
~/.cache/ms-playwright
${{ github.workspace }}/node_modules/playwright
key: playwright-${{ hashFiles('**/package-lock.json') }}
restore-keys: playwright-
- name: Install playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
shell: bash

View File

@ -1,6 +1,7 @@
name: 'Continuous Integration'
on:
workflow_call:
push:
branches: ['main']
pull_request:
@ -10,9 +11,6 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
HUSKY: 0
jobs:
build_app:
name: Build App
@ -23,20 +21,12 @@ jobs:
with:
fetch-depth: 2
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- uses: ./.github/actions/node-install
- name: Copy env
run: cp .env.example .env
- name: Build
run: npm run build
- uses: ./.github/actions/cache-build
build_docker:
name: Build Docker Image
@ -47,5 +37,31 @@ jobs:
with:
fetch-depth: 2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build Docker Image
run: ./docker/build.sh
uses: docker/build-push-action@v5
with:
push: false
context: .
file: ./docker/Dockerfile
tags: documenso-${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

29
.github/workflows/clean-cache.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: cleanup caches by a branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge

View File

@ -25,19 +25,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install Dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- name: Build Documenso
run: npm run build
- uses: ./.github/actions/node-install
- uses: ./.github/actions/cache-build
- name: Initialize CodeQL
uses: github/codeql-action/init@v2

View File

@ -6,29 +6,21 @@ on:
branches: ['main']
jobs:
e2e_tests:
name: "E2E Tests"
name: 'E2E Tests'
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- uses: ./.github/actions/node-install
- name: Start Services
run: npm run dx:up
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Generate Prisma Client
run: npm run prisma:generate -w @documenso/prisma
- uses: ./.github/actions/playwright-install
- name: Create the database
run: npm run prisma:migrate-dev
@ -36,6 +28,8 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
- uses: ./.github/actions/cache-build
- name: Run Playwright tests
run: npm run ci
@ -43,7 +37,7 @@ jobs:
if: always()
with:
name: test-results
path: "packages/app-tests/**/test-results/*"
path: 'packages/app-tests/**/test-results/*'
retention-days: 30
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}

133
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,133 @@
name: Publish Docker
on:
push:
branches: ['release']
jobs:
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
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: Build the docker image
env:
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,5 +1,3 @@
> 🚨 It is Launch Week #2 - Day 1: We launches teams 🎉 https://documen.so/day1
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px">
@ -29,7 +27,7 @@
<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>
@ -107,7 +105,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js
- Node.js (v18 or above)
- Postgres SQL Database
- Docker (optional)
@ -209,7 +207,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

@ -53,7 +53,7 @@ We are launching templates for Documenso! Templates are an easy way to reuse doc
/>
<figcaption className="text-center">
POV: You are a diligent german and create custom receipts with Documenso.
POV: You are a diligent german and create custom receipts with Documenso
</figcaption>
</figure>

View File

@ -0,0 +1,53 @@
---
title: Launch Week II - Day 3 - API
description: Documenso's mission is to create a plattform developers all around the world can build upon. Today we are releasing the first version of our public API, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-28
tags:
- Launch Week
- API
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/cb74d6cb-a127-4cac-a166-ad6b56c6140d"
autoPlay
loop
muted
></video>
> TLDR; The public API is now availible for all plans.
## Introducing the public Documenso API
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can:
- Get Documents (Individual or all Accessible)
- Upload Documents
- Delete Documents
- Create Documents from Templates
- Trigger Sending Documents for Singing
You can check out the detailed API documentation here:
> API DOCUMENTATION: [https://app.documenso.com/api/v1/openapi](https://app.documenso.com/api/v1/openapi)
## Pricing
We are building Documenso to be an open and extendable platform; therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost, as can teams. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
> Try the API here for free: [https://documen.so/api](https://documen.so/api)
## What's next for the API
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@ -0,0 +1,63 @@
---
title: Launch Week II - Day 4 - Webhooks and Zapier
description: If you want to integrate Documenso without fiddling with the API, we got you as well. You can now integrate Documenso via Zapier, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-29
tags:
- Launch Week
- Zapier
- Webhooks
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/3b60789d-8d27-4c66-ae5c-179e33c2e3e6"
autoPlay
loop
muted
></video>
> TLDR; Zapier Integration is now available for all plans.
## Introducing Zapier for Documenso
Day 4 🥳 Yesterday we introduced our [public API](https://documen.so/day3) for developers to build on Documenso. If you are not a developer or simple want a quicker integration this is for you: Documenso now support Zapier Integrations! Just connect your Documenso account via a simple login flow with Zapier and you will have access to Zapier's universe of integrations 💫 The integration currently supports:
- Document Created ([https://documen.so/zapier-created](https://documen.so/zapier-created))
- Document Sent ([Chttps://documen.so/zapier-sent](https://documen.so/zapier-sent))
- Document Opened ([https://documen.so/zapier-opened](https://documen.so/zapier-opened))
- Document Signed ([https://documen.so/zapier-signed](https://documen.so/zapier-signed))
- Document Completed ([https://documen.so/zapier-completed](https://documen.so/zapier-completed))
> ⚡️ You can create your own Zaps here: https://zapier.com/apps/documenso/integrations
Each event comes with extensive meta-data for you to use in Zapier. Missing something? Reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). We're always here and would love to hear from you :)
## Also Introducing for Documenso: Webhooks
To build the Zapier Integration, we needed a good webhooks concept, so we added that as well. Together with your Zaps, you can also now create customer webhooks in Documenso. You can try webhooks here for free: [https://documen.so/webhooks](https://documen.so/webhooks)
<figure>
<MdxNextImage
src="/blog/hooks.png"
width="1260"
height="630"
alt="Webhooks UI"
/>
<figcaption className="text-center">
Create unlimited custom webhooks with each plan.
</figcaption>
</figure>
## Pricing
Just like the API, we consider the Zapier integration and webhooks part of the open Documenso platform. Zapier is **available for all Documenso plans**, including free! [Login now](https://documen.so/login) to check it out.
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 4! Documenso now supports @Zapier 🤯 https://documen.so/day4"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@ -0,0 +1,61 @@
---
title: Launch Week II - Day 5 - Documenso Profiles
description: Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles will launch as soon as they are shiny.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-03-01
tags:
- Launch Week
- Profiles
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/89643ae2-2aa9-484c-a522-a0e35097c469"
autoPlay
loop
muted
></video>
> TLDR; Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles launch as soon as they are shiny.
## Introducing Documenso Profile Links
Day 5 - The Finale 🔥
Signing documents has always been between humans, and signing something together should be as frictionless as possible. It should also be async, so you don't force your counterpart to jog to their device to send something when you are ready. Today we are announcing the new Documenso Profiles:
<figure>
<MdxNextImage
src="/blog/profile.png"
width="1260"
height="813"
alt="Timur's Documenso Profile"
/>
<figcaption className="text-center">
Async > Sync: Add public templates to your Documenso Link and let people sign whenever they are ready.
</figcaption>
</figure>
Documenso profiles work with your existing templates. You can just add them to your public profile to let everyone with your link sign them. With profiles, we want to bring back the human aspect of signing.
By making profiles public, you can always access what your counterparty offers and make them more visible in the process. Long-term, we plan to add more to profiles to help you ensure the person you are dealing with is who they claim to be. Documenso wants to be the trust layer of the internet, and we want to start at the very fundamental level: The individual transaction.
Profiles are our first step towards bringing more trust into everything, simply by making the use of signing more frictionless. As there is more and more content of questionable origin out there, we want to support you in making it clear what you send out and what not.
## Pricing and Claiming
Documenso profile username can be claimed starting today. Documenso profiles will launch as soon as we are happy with the details ✨
- Long usernames (6 characters or more) come free with every account, e.g. **documenso.com/u/timurercan**
- Short usernames (5 characters or fewer) or less require any paid account ([Early Adopter](https://documen.so/claim-early-adopters-plan), [Teams](https://documen.so/teams) or Enterprise): **e.g., documenso.com/u/timur**
You can claim your username here: [https://documen.so/claim](https://documen.so/claim)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 5! You can now claim your username for the upcoming profile links 😮 https://documen.so/day5"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

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

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

View File

@ -36,7 +36,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: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View File

@ -2,8 +2,12 @@
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';
@ -17,6 +21,10 @@ 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);
@ -38,6 +46,31 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
'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

@ -1,6 +1,7 @@
'use client';
import { HTMLAttributes, useEffect, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';

View File

@ -14,7 +14,7 @@ export type MonthlyNewUsersChartProps = {
export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartProps) => {
const formattedData = [...data].reverse().map(({ month, count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'),
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
};
});
@ -32,6 +32,9 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [Number(value).toLocaleString('en-US'), 'New Users']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>

View File

@ -14,7 +14,7 @@ export type MonthlyTotalUsersChartProps = {
export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersChartProps) => {
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'),
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
};
});
@ -32,6 +32,9 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Total Users']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>

View File

@ -15,6 +15,7 @@ import { MonthlyNewUsersChart } from './monthly-new-users-chart';
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
import { TeamMembers } from './team-members';
import { OpenPageTooltip } from './tooltip';
import { Typefully } from './typefully';
export const metadata: Metadata = {
title: 'Open Startup',
@ -237,6 +238,8 @@ export default async function OpenPage() {
<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>

View File

@ -0,0 +1,39 @@
'use client';
import type { HTMLAttributes } from 'react';
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="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">
<FaXTwitter className="h-12 w-12" />
<Link href="https://typefully.com/documenso/stats" target="_blank">
<h1>Documenso on X</h1>
</Link>
<Button className="rounded-full" size="sm" asChild>
<Link href="https://typefully.com/documenso/stats" target="_blank">
View all stats
</Link>
</Button>
<Button className="rounded-full bg-white" size="sm" asChild>
<Link href="https://twitter.com/documenso" target="_blank">
Follow us on X
</Link>
</Button>
</div>
</div>
</div>
);
};

View File

@ -27,7 +27,7 @@ export default async function SinglePlayerModeSuccessPage({
return notFound();
}
const signatures = await getRecipientSignatures({ recipientId: document.Recipient.id });
const signatures = await getRecipientSignatures({ recipientId: document.Recipient[0].id });
return <SinglePlayerModeSuccess document={document} signatures={signatures} />;
}

View File

@ -191,7 +191,7 @@ export const SinglePlayerClient = () => {
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
@ -203,7 +203,7 @@ export const SinglePlayerClient = () => {
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
community plan
early adopter plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>

View File

@ -40,9 +40,9 @@ export const Callout = ({ starCount }: CalloutProps) => {
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Get the Early Adopters Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
Claim Early Adopter Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
$30/mo
</span>
</Button>

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import Image from 'next/image';

View File

@ -9,6 +9,7 @@ import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { HamburgerMenu } from './mobile-hamburger';
import { MobileNavigation } from './mobile-navigation';
@ -68,12 +69,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link>
<Link
href="https://app.documenso.com/signin"
href="https://app.documenso.com/signin?utm_source=marketing-header"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Sign in
</Link>
<Button className="rounded-full" size="sm" asChild>
<Link href="https://app.documenso.com/signup?utm_source=marketing-header" target="_blank">
Sign up
</Link>
</Button>
</div>
<HamburgerMenu

View File

@ -3,7 +3,8 @@
import Image from 'next/image';
import Link from 'next/link';
import { Variants, motion } from 'framer-motion';
import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu';
import { match } from 'ts-pattern';
@ -113,9 +114,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Get the Early Adopters Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
Claim Early Adopter Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
$30/mo
</span>
</Button>
@ -224,7 +225,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<span className="bg-primary text-black">
(in a non-legally binding, but heartfelt way)
</span>{' '}
and lock in the early supporter plan for forever, including everything we build this
and lock in the early adopter plan for forever, including everything we build this
year.
</p>

View File

@ -47,9 +47,13 @@ export const MENU_NAVIGATION_LINKS = [
text: 'Privacy',
},
{
href: 'https://app.documenso.com/signin',
href: 'https://app.documenso.com/signin?utm_source=marketing-header',
text: 'Sign in',
},
{
href: 'https://app.documenso.com/signup?utm_source=marketing-header',
text: 'Sign up',
},
];
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {

View File

@ -83,7 +83,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="rounded-full text-base" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-free-plan`}
target="_blank"
className="mt-6"
>
Signup Now
</Link>
</Button>
@ -98,7 +102,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</div>
<div
data-plan="community"
data-plan="early-adopter"
className="border-primary bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border-2 px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380]"
>
<p className="text-foreground text-4xl font-medium">Early Adopters</p>
@ -114,7 +118,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank">
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-early-adopter`}
target="_blank"
>
Signup Now
</Link>
</Button>

View File

@ -55,7 +55,7 @@ export const SinglePlayerModeSuccess = ({
<SigningCard3D
className="mt-8"
name={document.Recipient.name || document.Recipient.email}
name={document.Recipient[0].name || document.Recipient[0].email}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
@ -65,7 +65,7 @@ export const SinglePlayerModeSuccess = ({
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
<DocumentShareButton
documentId={document.id}
token={document.Recipient.token}
token={document.Recipient[0].token}
className="flex-1 bg-transparent backdrop-blur-sm"
/>
@ -86,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
>

View File

@ -199,7 +199,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
onSubmit={handleSubmit(onFormSubmit)}
>
<h3 className="text-2xl font-semibold">Sign up for the early adopters plan</h3>
<h3 className="text-xl font-semibold">Sign up to Early Adopter Plan</h3>
<p className="text-muted-foreground mt-2 text-xs">
with Timur Ercan & Lucas Smith from Documenso
</p>
@ -208,7 +208,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<AnimatePresence>
<motion.div key="email">
<label htmlFor="email" className="text-foreground text-lg font-semibold lg:text-xl">
<label htmlFor="email" className="text-foreground font-medium ">
Whats your email?
</label>
@ -220,7 +220,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<Input
id="email"
type="email"
placeholder=""
placeholder="your@example.com"
className="bg-background w-full pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
@ -265,11 +265,8 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
transform: 'translateX(25%)',
}}
>
<label
htmlFor="name"
className="text-foreground text-lg font-semibold lg:text-xl"
>
and your name?
<label htmlFor="name" className="text-foreground font-medium ">
And your name?
</label>
<Controller

View File

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

View File

@ -19,7 +19,6 @@
"@documenso/ee": "*",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
@ -44,20 +43,21 @@
"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",
"typescript": "5.2.2",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39"
"@types/ua-parser-js": "^0.7.39",
"typescript": "5.2.2"
},
"overrides": {
"next-auth": {

View File

@ -0,0 +1,69 @@
'use client';
import Link from 'next/link';
import { type Document, DocumentStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminActionsProps = {
className?: string;
document: Document;
};
export const AdminActions = ({ className, document }: AdminActionsProps) => {
const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Document resealed',
});
},
onError: () => {
toast({
title: 'Error',
description: 'Failed to reseal document',
variant: 'destructive',
});
},
});
return (
<div className={cn('flex gap-x-4', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.status !== DocumentStatus.COMPLETED}
onClick={() => resealDocument({ id: document.id })}
>
Reseal document
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
</Button>
</div>
);
};

View File

@ -0,0 +1,86 @@
import { DateTime } from 'luxon';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Badge } from '@documenso/ui/primitives/badge';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
import { AdminActions } from './admin-actions';
import { RecipientItem } from './recipient-item';
type AdminDocumentDetailsPageProps = {
params: {
id: string;
};
};
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
const document = await getEntireDocument({ id: Number(params.id) });
return (
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
</div>
{document.deletedAt && (
<Badge size="large" variant="destructive">
Deleted
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
</div>
<div>
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">Admin Actions</h2>
<AdminActions className="mt-2" document={document} />
<hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.Recipient.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
className="rounded-lg border"
>
<AccordionTrigger className="px-4">
<div className="flex items-center gap-x-4">
<h4 className="font-semibold">{recipient.name}</h4>
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<RecipientItem recipient={recipient} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
'use client';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
type Field,
type Recipient,
type Signature,
SigningStatus,
} from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAdminUpdateRecipientFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
type TAdminUpdateRecipientFormSchema = z.infer<typeof ZAdminUpdateRecipientFormSchema>;
export type RecipientItemProps = {
recipient: Recipient & {
Field: Array<
Field & {
Signature: Signature | null;
}
>;
};
};
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
const { toast } = useToast();
const router = useRouter();
const form = useForm<TAdminUpdateRecipientFormSchema>({
defaultValues: {
name: recipient.name,
email: recipient.email,
},
});
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
try {
await updateRecipient({
id: recipient.id,
name,
email,
});
toast({
title: 'Recipient updated',
description: 'The recipient has been updated successfully',
});
router.refresh();
} catch (error) {
toast({
title: 'Failed to update recipient',
description: error.message,
variant: 'destructive',
});
}
};
return (
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onUpdateRecipientFormSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
disabled={
form.formState.isSubmitting || recipient.signingStatus === SigningStatus.SIGNED
}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
Update Recipient
</Button>
</div>
</fieldset>
</form>
</Form>
<hr className="my-4" />
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
<DataTable
data={recipient.Field}
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <div>{row.original.type}</div>,
},
{
header: 'Inserted',
accessorKey: 'inserted',
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
},
{
header: 'Value',
accessorKey: 'customText',
cell: ({ row }) => <div>{row.original.customText}</div>,
},
{
header: 'Signature',
accessorKey: 'signature',
cell: ({ row }) => (
<div>
{row.original.Signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span>
)}
{row.original.Signature?.signatureImageAsBase64 && (
<img
src={row.original.Signature.signatureImageAsBase64}
alt="Signature"
className="h-12 w-full dark:invert"
/>
)}
</div>
),
},
]}
/>
</div>
);
};

View File

@ -1,125 +0,0 @@
'use client';
import { useTransition } from 'react';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Document, User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
export type DocumentsDataTableProps = {
results: FindResultSet<
Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
}
>;
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<div className="block max-w-[5rem] truncate font-medium md:max-w-[10rem]">
{row.original.title}
</div>
);
},
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,150 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
// export type AdminDocumentResultsProps = {};
export const AdminDocumentResults = () => {
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [term, setTerm] = useState(() => searchParams?.get?.('term') ?? '');
const debouncedTerm = useDebouncedValue(term, 500);
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data: findDocumentsData, isLoading: isFindDocumentsLoading } =
trpc.admin.findDocuments.useQuery(
{
term: debouncedTerm,
page: page || 1,
perPage: perPage || 20,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (newPage: number, newPerPage: number) => {
updateSearchParams({
page: newPage,
perPage: newPerPage,
});
};
return (
<div>
<Input
type="search"
placeholder="Search by document title"
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<div className="relative mt-4">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<Link
href={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}
</Link>
);
},
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
]}
data={findDocumentsData?.data ?? []}
perPage={findDocumentsData?.perPage ?? 20}
currentPage={findDocumentsData?.currentPage ?? 1}
totalPages={findDocumentsData?.totalPages ?? 1}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isFindDocumentsLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
</div>
);
};

View File

@ -1,28 +1,12 @@
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { DocumentsDataTable } from './data-table';
export type DocumentsPageProps = {
searchParams?: {
page?: string;
perPage?: string;
};
};
export default async function Documents({ searchParams = {} }: DocumentsPageProps) {
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const results = await findDocuments({
page,
perPage,
});
import { AdminDocumentResults } from './document-results';
export default function AdminDocumentsPage() {
return (
<div>
<h2 className="text-4xl font-semibold">Manage documents</h2>
<div className="mt-8">
<DocumentsDataTable results={results} />
<AdminDocumentResults />
</div>
</div>
);

View File

@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteUserDialogProps = {
className?: string;
user: User;
};
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [email, setEmail] = useState('');
const { mutateAsync: deleteUser, isLoading: isDeletingUser } =
trpc.admin.deleteUser.useMutation();
const onDeleteAccount = async () => {
try {
await deleteUser({
id: user.id,
email,
});
toast({
title: 'Account deleted',
description: 'The account has been deleted successfully.',
duration: 5000,
});
router.push('/admin/users');
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.',
});
}
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
variant="neutral"
>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertDescription className="mr-2">
Delete the users account and all its contents. This action is irreversible and will
cancel their subscription, so proceed with caution.
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
To confirm, please enter the accounts email address <br />({user.email}).
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button
onClick={onDeleteAccount}
loading={isDeletingUser}
variant="destructive"
disabled={email !== user.email}
>
{isDeletingUser ? 'Deleting account...' : 'Delete Account'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};

View File

@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
@ -20,9 +20,10 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DeleteUserDialog } from './delete-user-dialog';
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
@ -137,6 +138,10 @@ export default function UserPage({ params }: { params: { id: number } }) {
</fieldset>
</form>
</Form>
<hr className="my-4" />
{user && <DeleteUserDialog user={user} />}
</div>
);
}

View File

@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []),
]);
const individualPriceIds = individualPrices.map((price) => price.id);

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

@ -1,7 +1,10 @@
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
import { UpcomingProfileClaimTeaser } from './upcoming-profile-claim-teaser';
export type DocumentsPageProps = {
searchParams?: DocumentsPageViewProps['searchParams'];
@ -11,6 +14,12 @@ export const metadata: Metadata = {
title: 'Documents',
};
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
return <DocumentsPageView searchParams={searchParams} />;
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const { user } = await getRequiredServerComponentSession();
return (
<>
<UpcomingProfileClaimTeaser user={user} />
<DocumentsPageView searchParams={searchParams} />
</>
);
}

View File

@ -0,0 +1,52 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { User } from '@documenso/prisma/client';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
export type UpcomingProfileClaimTeaserProps = {
user: User;
};
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [claimed, setClaimed] = useState(false);
const onOpenChange = useCallback(
(open: boolean) => {
if (!open && !claimed) {
toast({
title: 'Claim your profile later',
description: 'You can claim your profile later on by going to your profile settings!',
});
}
setOpen(open);
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
},
[claimed, toast],
);
useEffect(() => {
const hasShownProfileClaimDialog =
localStorage.getItem('app.hasShownProfileClaimDialog') === 'true';
if (!user.url && !hasShownProfileClaimDialog) {
onOpenChange(true);
}
}, [onOpenChange, user.url]);
return (
<ClaimPublicProfileDialogForm
open={open}
onOpenChange={onOpenChange}
onClaimed={() => setClaimed(true)}
user={user}
/>
);
};

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

@ -0,0 +1,46 @@
'use client';
import { useState } from 'react';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
export type ClaimProfileAlertDialogProps = {
className?: string;
user: User;
};
export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDialogProps) => {
const [open, setOpen] = useState(false);
return (
<>
<Alert
className={cn(
'flex flex-col items-center justify-between gap-4 p-6 md:flex-row',
className,
)}
variant="neutral"
>
<div>
<AlertTitle>{user.url ? 'Update your profile' : 'Claim your profile'}</AlertTitle>
<AlertDescription className="mr-2">
{user.url
? 'Profiles are coming soon! Update your profile username to reserve your corner of the signing revolution.'
: 'Profiles are coming soon! Claim your profile username now to reserve your corner of the signing revolution.'}
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Button onClick={() => setOpen(true)}>{user.url ? 'Update Now' : 'Claim Now'}</Button>
</div>
</Alert>
<ClaimPublicProfileDialogForm open={open} onOpenChange={setOpen} user={user} />
</>
);
};

View File

@ -112,7 +112,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
variant="destructive"
disabled={hasTwoFactorAuthentication}
>
{isDeletingAccount ? 'Deleting account...' : 'Delete Account'}
{isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'}
</Button>
</DialogFooter>
</DialogContent>

View File

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

View File

@ -1,5 +1,8 @@
import type { Metadata } from 'next';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
export const metadata: Metadata = {
@ -9,11 +12,14 @@ export const metadata: Metadata = {
export default function SettingsSecurityActivityPage() {
return (
<div>
<h3 className="text-2xl font-semibold">Security activity</h3>
<p className="text-muted-foreground mt-2 text-sm">
View all recent security activity related to your account.
</p>
<SettingsHeader
title="Security activity"
subtitle="View all recent security activity related to your account."
>
<div>
<ActivityPageBackButton />
</div>
</SettingsHeader>
<hr className="my-4" />

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

@ -54,7 +54,7 @@ export default async function TeamsSettingBillingPage({ params }: TeamsSettingsB
<CardContent className="flex flex-row items-center justify-between p-4">
<div className="flex flex-col text-sm">
<p className="text-foreground font-semibold">
Current plan: {teamSubscription ? 'Team' : 'Community Team'}
Current plan: {teamSubscription ? 'Team' : 'Early Adopter Team'}
</p>
<p className="text-muted-foreground mt-0.5">

View File

@ -9,17 +9,19 @@ export const metadata: Metadata = {
export default function ForgotPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Email sent!</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">Email sent!</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
A password reset email has been sent, if you have an account you should see it in your inbox
shortly.
</p>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
A password reset email has been sent, if you have an account you should see it in your
inbox shortly.
</p>
<Button asChild>
<Link href="/signin">Return to sign in</Link>
</Button>
<Button asChild>
<Link href="/signin">Return to sign in</Link>
</Button>
</div>
</div>
);
}

View File

@ -9,22 +9,24 @@ export const metadata: Metadata = {
export default function ForgotPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Forgot your password?</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-3xl font-semibold">Forgot your password?</h1>
<p className="text-muted-foreground mt-2 text-sm">
No worries, it happens! Enter your email and we'll email you a special link to reset your
password.
</p>
<p className="text-muted-foreground mt-2 text-sm">
No worries, it happens! Enter your email and we'll email you a special link to reset your
password.
</p>
<ForgotPasswordForm className="mt-4" />
<ForgotPasswordForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Remembered your password?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign In
</Link>
</p>
<p className="text-muted-foreground mt-6 text-center text-sm">
Remembered your password?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign In
</Link>
</p>
</div>
</div>
);
}

View File

@ -10,9 +10,9 @@ type UnauthenticatedLayoutProps = {
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
return (
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div className="relative flex w-full max-w-md items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<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
src={backgroundPattern}
alt="background pattern"
@ -20,7 +20,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
/>
</div>
<div className="w-full">{children}</div>
<div className="relative w-full">{children}</div>
</div>
</main>
);

View File

@ -19,19 +19,21 @@ export default async function ResetPasswordPage({ params: { token } }: ResetPass
}
return (
<div className="w-full">
<h1 className="text-4xl font-semibold">Reset Password</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">Reset Password</h1>
<p className="text-muted-foreground mt-2 text-sm">Please choose your new password </p>
<p className="text-muted-foreground mt-2 text-sm">Please choose your new password </p>
<ResetPasswordForm token={token} className="mt-4" />
<ResetPasswordForm token={token} className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
</div>
</div>
);
}

View File

@ -9,17 +9,19 @@ export const metadata: Metadata = {
export default function ResetPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-3xl font-semibold">Unable to reset password</h1>
<p className="text-muted-foreground mt-2 text-sm">
The token you have used to reset your password is either expired or it never existed. If you
have still forgotten your password, please request a new reset link.
</p>
<p className="text-muted-foreground mt-2 text-sm">
The token you have used to reset your password is either expired or it never existed. If
you have still forgotten your password, please request a new reset link.
</p>
<Button className="mt-4" asChild>
<Link href="/signin">Return to sign in</Link>
</Button>
<Button className="mt-4" asChild>
<Link href="/signin">Return to sign in</Link>
</Button>
</div>
</div>
);
}

View File

@ -30,36 +30,27 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
}
return (
<div>
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
<div className="w-screen max-w-lg px-4">
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
<h1 className="text-2xl font-semibold">Sign in to your account</h1>
<p className="text-muted-foreground/60 mt-2 text-sm">
Welcome back, we are lucky to have you.
</p>
<SignInForm
className="mt-4"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
<p className="text-muted-foreground mt-2 text-sm">
Welcome back, we are lucky to have you.
</p>
)}
<p className="mt-2.5 text-center">
<Link
href="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
Forgot your password?
</Link>
</p>
<hr className="-mx-6 my-4" />
<SignInForm initialEmail={email || undefined} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
Sign up
</Link>
</p>
)}
</div>
</div>
);
}

View File

@ -1,5 +1,4 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
@ -7,7 +6,7 @@ import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpForm } from '~/components/forms/signup';
import { SignUpFormV2 } from '~/components/forms/v2/signup';
export const metadata: Metadata = {
title: 'Sign Up',
@ -34,26 +33,10 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
}
return (
<div>
<h1 className="text-4xl font-semibold">Create a new account</h1>
<p className="text-muted-foreground/60 mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and beautiful
signing is within your grasp.
</p>
<SignUpForm
className="mt-4"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</div>
<SignUpFormV2
className="w-screen max-w-screen-2xl px-4 md:px-16"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
);
}

View File

@ -29,16 +29,18 @@ export default async function AcceptInvitationPage({
if (!teamMemberInvite) {
return (
<div>
<h1 className="text-4xl font-semibold">Invalid token</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">Invalid token</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This token is invalid or has expired. Please contact your team for a new invitation.
</p>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This token is invalid or has expired. Please contact your team for a new invitation.
</p>
<Button asChild>
<Link href="/">Return</Link>
</Button>
<Button asChild>
<Link href="/">Return</Link>
</Button>
</div>
</div>
);
}

View File

@ -22,16 +22,18 @@ export default async function VerifyTeamEmailPage({ params: { token } }: VerifyT
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
return (
<div>
<h1 className="text-4xl font-semibold">Invalid link</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">Invalid link</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This link is invalid or has expired. Please contact your team to resend a verification.
</p>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This link is invalid or has expired. Please contact your team to resend a verification.
</p>
<Button asChild>
<Link href="/">Return</Link>
</Button>
<Button asChild>
<Link href="/">Return</Link>
</Button>
</div>
</div>
);
}

View File

@ -25,17 +25,19 @@ export default async function VerifyTeamTransferPage({
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
return (
<div>
<h1 className="text-4xl font-semibold">Invalid link</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">Invalid link</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This link is invalid or has expired. Please contact your team to resend a transfer
request.
</p>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This link is invalid or has expired. Please contact your team to resend a transfer
request.
</p>
<Button asChild>
<Link href="/">Return</Link>
</Button>
<Button asChild>
<Link href="/">Return</Link>
</Button>
</div>
</div>
);
}

View File

@ -4,23 +4,25 @@ import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-
export default function UnverifiedAccount() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
<div className="w-screen max-w-lg px-4">
<div className="flex items-start">
<div className="mr-4 mt-1 hidden md:block">
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
<p className="text-muted-foreground mt-4">
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</p>
<p className="text-muted-foreground mt-4">
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</p>
<p className="text-muted-foreground mt-4">
If you don't find the confirmation link in your inbox, you can request a new one below.
</p>
<p className="text-muted-foreground mt-4">
If you don't find the confirmation link in your inbox, you can request a new one below.
</p>
<SendConfirmationEmailForm />
<SendConfirmationEmailForm />
</div>
</div>
</div>
);

View File

@ -14,15 +14,17 @@ export type PageProps = {
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) {
return (
<div className="w-full">
<div className="mb-4 text-red-300">
<XOctagon />
</div>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<div className="mb-4 text-red-300">
<XOctagon />
</div>
<h2 className="text-4xl font-semibold">No token provided</h2>
<p className="text-muted-foreground mt-2 text-base">
It seems that there is no token provided. Please check your email and try again.
</p>
<h2 className="text-4xl font-semibold">No token provided</h2>
<p className="text-muted-foreground mt-2 text-base">
It seems that there is no token provided. Please check your email and try again.
</p>
</div>
</div>
);
}
@ -31,22 +33,24 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
if (verified === null) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
<p className="text-muted-foreground mt-4">
We were unable to verify your email. If your email is not verified already, please try
again.
</p>
<p className="text-muted-foreground mt-4">
We were unable to verify your email. If your email is not verified already, please try
again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
</div>
);
@ -54,17 +58,41 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
if (!verified) {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
<p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token,
please check your email and try again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
<p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token, please
check your email and try again.
Your email has been successfully confirmed! You can now use all features of Documenso.
</p>
<Button className="mt-4" asChild>
@ -72,26 +100,6 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
</Button>
</div>
</div>
);
}
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
<p className="text-muted-foreground mt-4">
Your email has been successfully confirmed! You can now use all features of Documenso.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@ -11,22 +11,26 @@ export const metadata: Metadata = {
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
Uh oh! Looks like you're missing a token
</h2>
<p className="text-muted-foreground mt-4">
It seems that there is no token provided, if you are trying to verify your email please
follow the link in your email.
</p>
<p className="text-muted-foreground mt-4">
It seems that there is no token provided, if you are trying to verify your email please
follow the link in your email.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
</div>
);

View File

@ -1,3 +1,11 @@
'use client';
export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';
import dynamic from 'next/dynamic';
const Docs = dynamic(async () => import('@documenso/api/v1/api-documentation'), {
ssr: false,
});
export default function OpenApiDocsPage() {
return <Docs />;
}

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

@ -57,7 +57,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
key={href}
href={`${rootHref}${href}`}
className={cn(
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`,

View File

@ -7,7 +7,6 @@ import { useParams } from 'next/navigation';
import { MenuIcon, SearchIcon } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { getRootHref } from '@documenso/lib/utils/params';
import type { User } from '@documenso/prisma/client';
@ -19,7 +18,6 @@ import { CommandMenu } from '../common/command-menu';
import { DesktopNav } from './desktop-nav';
import { MenuSwitcher } from './menu-switcher';
import { MobileNavigation } from './mobile-navigation';
import { ProfileDropdown } from './profile-dropdown';
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
user: User;
@ -29,10 +27,6 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
const params = useParams();
const { getFlag } = useFeatureFlags();
const isTeamsEnabled = getFlag('app_teams');
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
@ -47,34 +41,6 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
return () => window.removeEventListener('scroll', onScroll);
}, []);
if (!isTeamsEnabled) {
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className,
)}
{...props}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link
href="/"
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Logo className="h-6 w-auto" />
</Link>
<DesktopNav />
<div className="flex gap-x-4 md:ml-8">
<ProfileDropdown user={user} />
</div>
</div>
</header>
);
}
return (
<header
className={cn(
@ -86,7 +52,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link
href={getRootHref(params)}
href={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
>
<Logo className="h-6 w-auto" />

View File

@ -1,177 +0,0 @@
'use client';
import Link from 'next/link';
import {
Braces,
CreditCard,
FileSpreadsheet,
Lock,
LogOut,
User as LucideUser,
Monitor,
Moon,
Palette,
Sun,
UserCog,
} from 'lucide-react';
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { LuGithub } from 'react-icons/lu';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
export type ProfileDropdownProps = {
user: User;
};
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { getFlag } = useFeatureFlags();
const { theme, setTheme } = useTheme();
const isUserAdmin = isAdmin(user);
const isBillingEnabled = getFlag('app_billing');
const avatarFallback = user.name
? extractInitials(user.name)
: user.email.slice(0, 1).toUpperCase();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
title="Profile Dropdown"
className="relative h-10 w-10 rounded-full"
>
<Avatar className="h-10 w-10">
<AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-[60] w-56" align="end" forceMount>
<DropdownMenuLabel>Account</DropdownMenuLabel>
{isUserAdmin && (
<>
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<UserCog className="mr-2 h-4 w-4" />
Admin
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="cursor-pointer">
<LucideUser className="mr-2 h-4 w-4" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings/security" className="cursor-pointer">
<Lock className="mr-2 h-4 w-4" />
Security
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings/tokens" className="cursor-pointer">
<Braces className="mr-2 h-4 w-4" />
API Tokens
</Link>
</DropdownMenuItem>
{isBillingEnabled && (
<DropdownMenuItem asChild>
<Link href="/settings/billing" className="cursor-pointer">
<CreditCard className="mr-2 h-4 w-4" />
Billing
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/templates" className="cursor-pointer">
<FileSpreadsheet className="mr-2 h-4 w-4" />
Templates
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="mr-2 h-4 w-4" />
Themes
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="z-[60]">
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenuRadioItem value="light">
<Sun className="mr-2 h-4 w-4" /> Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href="https://github.com/documenso/documenso"
className="cursor-pointer"
target="_blank"
>
<LuGithub className="mr-2 h-4 w-4" />
Star on Github
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
void signOut({
callbackUrl: '/',
})
}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -0,0 +1,22 @@
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@documenso/ui/primitives/button';
export default function ActivityPageBackButton() {
const router = useRouter();
return (
<div>
<Button
className="flex-shrink-0"
variant="secondary"
onClick={() => {
void router.back();
}}
>
Back
</Button>
</div>
);
}

View File

@ -19,7 +19,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
const isTeamsEnabled = getFlag('app_teams');
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
@ -36,20 +35,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{isTeamsEnabled && (
<Link href="/settings/teams">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
Teams
</Button>
</Link>
)}
<Link href="/settings/teams">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
Teams
</Button>
</Link>
<Link href="/settings/security">
<Button

View File

@ -1,15 +1,18 @@
import React from 'react';
import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = {
title: string;
subtitle: string;
children?: React.ReactNode;
className?: string;
};
export const SettingsHeader = ({ children, title, subtitle }: SettingsHeaderProps) => {
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
return (
<>
<div className="flex flex-row items-center justify-between">
<div className={cn('flex flex-row items-center justify-between', className)}>
<div>
<h3 className="text-lg font-medium">{title}</h3>

View File

@ -19,7 +19,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
const isTeamsEnabled = getFlag('app_teams');
return (
<div
@ -39,20 +38,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{isTeamsEnabled && (
<Link href="/settings/teams">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
Teams
</Button>
</Link>
)}
<Link href="/settings/teams">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
Teams
</Button>
</Link>
<Link href="/settings/security">
<Button

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

@ -0,0 +1,197 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '../ui/user-profile-skeleton';
export const ZClaimPublicProfileFormSchema = z.object({
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
});
export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>;
export type ClaimPublicProfileDialogFormProps = {
open: boolean;
onOpenChange?: (open: boolean) => void;
onClaimed?: () => void;
user: User;
};
export const ClaimPublicProfileDialogForm = ({
open,
onOpenChange,
onClaimed,
user,
}: ClaimPublicProfileDialogFormProps) => {
const { toast } = useToast();
const [claimed, setClaimed] = useState(false);
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TClaimPublicProfileFormSchema>({
values: {
url: user.url || '',
},
resolver: zodResolver(ZClaimPublicProfileFormSchema),
});
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => {
try {
await updatePublicProfile({
url,
});
setClaimed(true);
onClaimed?.();
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
form.setError('url', {
type: 'manual',
message: 'This username is already taken',
});
} else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
form.setError('url', {
type: 'manual',
message: error.message,
});
} else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
toast({
title: 'An error occurred',
description: error.userMessage ?? error.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to save your details. Please try again later.',
});
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent position="center" className="max-w-lg overflow-hidden">
{!claimed && (
<>
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">
Introducing public profiles!
</DialogTitle>
<DialogDescription className="text-center">
Reserve your Documenso public profile username
</DialogDescription>
</DialogHeader>
<Image src={profileClaimTeaserImage} alt="profile claim teaser" />
<Form {...form}>
<form
className={cn(
'to-background -mt-32 flex w-full flex-col bg-gradient-to-b from-transparent to-15% pt-16 md:-mt-44',
)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="-mt-6 flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile username</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2" {...field} />
</FormControl>
<FormMessage />
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
)}
/>
</fieldset>
<div className="mt-4 text-center">
<Button type="submit" loading={isSubmitting}>
Claim your username
</Button>
</div>
</form>
</Form>
</>
)}
{claimed && (
<>
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">All set!</DialogTitle>
<DialogDescription className="text-center">
We will let you know as soon as this features is launched
</DialogDescription>
</DialogHeader>
<UserProfileSkeleton className="mt-4" user={user} rows={1} />
<div className="to-background -mt-12 flex w-full flex-col items-center bg-gradient-to-b from-transparent to-15% px-4 pt-8 md:-mt-12">
<Button className="w-full" onClick={() => onOpenChange?.(false)}>
Can't wait!
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -2,6 +2,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
@ -195,9 +196,11 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
@ -209,9 +212,19 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<p className="mt-2 text-right">
<Link
href="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
Forgot your password?
</Link>
</p>
<FormMessage />
</FormItem>
)}

View File

@ -0,0 +1,460 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod';
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '~/components/ui/user-profile-skeleton';
import { UserProfileTimur } from '~/components/ui/user-profile-timur';
const SIGN_UP_REDIRECT_PATH = '/documents';
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
export const ZSignUpFormV2Schema = z
.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
password: ZPasswordSchema,
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'We need a username to create your profile' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
})
.refine(
(data) => {
const { name, email, password } = data;
return !password.includes(name) && !password.includes(email.split('@')[0]);
},
{
message: 'Password should not be common or based on personal information',
},
);
export type TSignUpFormV2Schema = z.infer<typeof ZSignUpFormV2Schema>;
export type SignUpFormV2Props = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
};
export const SignUpFormV2 = ({
className,
initialEmail,
isGoogleSSOEnabled,
}: SignUpFormV2Props) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
const searchParams = useSearchParams();
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
const utmSrc = searchParams?.get('utm_source') ?? null;
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TSignUpFormV2Schema>({
values: {
name: '',
email: initialEmail ?? '',
password: '',
signature: '',
url: '',
},
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormV2Schema),
});
const isSubmitting = form.formState.isSubmitting;
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) => {
try {
await signup({ name, email, password, signature, url });
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
});
analytics.capture('App: User Sign Up', {
email,
timestamp: new Date().toISOString(),
custom_campaign_params: { src: utmSrc },
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
form.setError('url', {
type: 'manual',
message: 'This username has already been taken',
});
} else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
form.setError('url', {
type: 'manual',
message: error.message,
});
} else if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you up. Please try again later.',
variant: 'destructive',
});
}
}
};
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
<div className="absolute -inset-8 -z-[2] backdrop-blur">
<Image
src={communityCardsImage}
fill={true}
alt="community-cards"
className="dark:brightness-95 dark:contrast-[70%] dark:invert"
/>
</div>
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
User profiles are coming soon!
</div>
<AnimatePresence>
{step === 'BASIC_DETAILS' ? (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
) : (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileSkeleton
user={{ name, url }}
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
)}
</AnimatePresence>
<div />
</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">
{step === 'BASIC_DETAILS' && (
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">Create a new account</h1>
<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>
</div>
)}
{step === 'CLAIM_USERNAME' && (
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">Claim your username now</h1>
<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>
</div>
)}
<hr className="-mx-6 my-4" />
<Form {...form}>
<form
className="flex w-full flex-1 flex-col gap-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
{step === 'BASIC_DETAILS' && (
<fieldset
className={cn(
'flex h-[500px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[600px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<FormItem>
<FormLabel>Sign Here</FormLabel>
<FormControl>
<SignaturePad
className="h-36 w-full"
disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with Google
</Button>
</>
)}
<p className="text-muted-foreground mt-4 text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</fieldset>
)}
{step === 'CLAIM_USERNAME' && (
<fieldset
className={cn(
'flex h-[500px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[600px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile username</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2 lowercase" {...field} />
</FormControl>
<FormMessage />
<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>
)}
/>
</fieldset>
)}
<div className="mt-6">
{step === 'BASIC_DETAILS' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">Basic details</span> 1/2
</p>
)}
{step === 'CLAIM_USERNAME' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">Claim username</span> 2/2
</p>
)}
<div className="bg-foreground/40 relative mt-4 h-1.5 rounded-full">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0 rounded-full"
style={{
width: step === 'BASIC_DETAILS' ? '50%' : '100%',
}}
/>
</div>
</div>
<div className="flex items-center gap-x-4">
{/* Go back button, disabled if step is basic details */}
<Button
type="button"
size="lg"
variant="secondary"
className="flex-1"
disabled={step === 'BASIC_DETAILS' || form.formState.isSubmitting}
onClick={() => setStep('BASIC_DETAILS')}
>
Back
</Button>
{/* Continue button */}
{step === 'BASIC_DETAILS' && (
<Button
type="button"
size="lg"
className="flex-1 disabled:cursor-not-allowed"
disabled={!canContinue}
loading={form.formState.isSubmitting}
onClick={() => setStep('CLAIM_USERNAME')}
>
Next
</Button>
)}
{/* Sign up button */}
{step === 'CLAIM_USERNAME' && (
<Button
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
type="submit"
size="lg"
className="flex-1"
>
Complete
</Button>
)}
</div>
</form>
</Form>
</div>
</div>
);
};

View File

@ -0,0 +1,83 @@
'use client';
import { File, User2 } from 'lucide-react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { User } from '@documenso/prisma/client';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type UserProfileSkeletonProps = {
className?: string;
user: Pick<User, 'name' | 'url'>;
rows?: number;
};
export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
className,
)}
>
<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">
<div className="bg-primary/10 rounded-full p-1.5">
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
</div>
</div>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
</div>
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
Documents
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
Sign
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,87 @@
'use client';
import Image from 'next/image';
import { File } from 'lucide-react';
import timurImage from '@documenso/assets/images/timur.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type UserProfileTimurProps = {
className?: string;
rows?: number;
};
export const UserProfileTimur = ({ className, rows = 2 }: UserProfileTimurProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
className,
)}
>
<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>
<div className="mt-4">
<Image
src={timurImage}
className="h-20 w-20 rounded-full"
alt="image of timur ercan founder of documenso"
/>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="text-2xl font-semibold">Timur Ercan</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
</div>
<p className="text-muted-foreground mt-4 max-w-[40ch] text-center text-sm">Hey Im Timur</p>
<p className="text-muted-foreground mt-1 max-w-[40ch] text-center text-sm">
Pick any of the following agreements below and start signing to get started
</p>
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
Documents
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
Sign
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -32,6 +32,7 @@ export function PostHogPageview() {
// Do nothing.
});
},
custom_campaign_params: ['src'],
});
}

View File

@ -32,6 +32,7 @@ RUN apk add --no-cache libc6-compat
RUN apk add --no-cache jq
# Required for node_modules/aws-crt
RUN apk add --no-cache make cmake g++
WORKDIR /app
# Disable husky from installing hooks
@ -80,6 +81,7 @@ WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
COPY --from=installer /app/apps/web/next.config.js .
@ -91,4 +93,18 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
CMD node apps/web/server.js
# Copy the prisma binary, schema and migrations
COPY --from=installer --chown=nextjs:nodejs /app/packages/prisma/schema.prisma ./packages/prisma/schema.prisma
COPY --from=installer --chown=nextjs:nodejs /app/packages/prisma/migrations ./packages/prisma/migrations
COPY --from=installer --chown=nextjs:nodejs /app/node_modules/prisma/ ./node_modules/prisma/
COPY --from=installer --chown=nextjs:nodejs /app/node_modules/@prisma/ ./node_modules/@prisma/
# Symlink the prisma binary
RUN mkdir node_modules/.bin
RUN ln -s /app/node_modules/prisma/build/index.js ./node_modules/.bin/prisma
# Get the start script from docker/start.sh
COPY --chown=nextjs:nodejs ./docker/start.sh ./start.sh
CMD ["sh", "start.sh"]

140
docker/README.md Normal file
View File

@ -0,0 +1,140 @@
# Docker Setup for Documenso
The following guide will walk you through setting up Documenso using Docker. You can choose between a production setup using Docker Compose or a standalone container.
## Prerequisites
Before you begin, ensure that you have the following installed:
- Docker
- Docker Compose (if using the Docker Compose setup)
## Option 1: Production Docker Compose Setup
This setup includes a PostgreSQL database and the Documenso application. You will need to provide your own SMTP details via environment variables.
1. Download the Docker Compose file from the Documenso repository: [compose.yml](https://raw.githubusercontent.com/documenso/documenso/release/docker/production/compose.yml)
2. Navigate to the directory containing the `compose.yml` file.
3. Create a `.env` file in the same directory and add your SMTP details as well as a few extra environment variables, following the example below:
```
NEXTAUTH_SECRET="<your-secret>"
NEXT_PRIVATE_ENCRYPTION_KEY="<your-key>"
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-secondary-key>"
NEXT_PUBLIC_WEBAPP_URL="<your-url>"
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
NEXT_PRIVATE_SMTP_HOST="<your-host>"
NEXT_PRIVATE_SMTP_PORT=<your-port>
NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
```
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
```
This will start the PostgreSQL database and the Documenso application containers.
5. Access the Documenso application by visiting `http://localhost:3000` in your web browser.
## Option 2: Standalone Docker Container
If you prefer to host the Documenso application on your container provider of choice, you can use the pre-built Docker image from DockerHub or GitHub's Package Registry. Note that you will need to provide your own database and SMTP host.
1. Pull the Documenso Docker image:
```
docker pull documenso/documenso
```
Or, if using GitHub's Package Registry:
```
docker pull ghcr.io/documenso/documenso
```
2. Run the Docker container, providing the necessary environment variables for your database and SMTP host:
```
docker run -d \
-p 3000:3000 \
-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
```
Replace the placeholders with your actual database and SMTP details.
1. Access the Documenso application by visiting the URL you provided in the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
## Success
You have now successfully set up Documenso using Docker. You can start organizing and managing your documents efficiently. If you encounter any issues or have further questions, please refer to the official Documenso documentation or seek assistance from the community.
## Advanced Configuration
The environment variables listed above are a subset of those that are available for configuring Documenso. For a complete list of environment variables and their descriptions, refer to the table below:
Here's a markdown table documenting all the provided environment variables:
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port to run the Documenso application on, defaults to `3000`. |
| `NEXTAUTH_URL` | The URL for the NextAuth.js authentication service. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `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). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |

View File

@ -5,15 +5,10 @@ command -v docker >/dev/null 2>&1 || {
exit 1
}
command -v jq >/dev/null 2>&1 || {
echo "jq is not installed. Please install jq and try again."
exit 1
}
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
APP_VERSION="$(jq -r '.version' "$MONOREPO_ROOT/apps/web/package.json")"
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
echo "Building docker image for monorepo at $MONOREPO_ROOT"
@ -22,7 +17,10 @@ echo "Git SHA: $GIT_SHA"
docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \
-t "documenso:latest" \
-t "documenso:$GIT_SHA" \
-t "documenso:$APP_VERSION" \
-t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT"

35
docker/buildx-and-push.sh Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
command -v docker >/dev/null 2>&1 || {
echo "Docker is not running. Please start Docker and try again."
exit 1
}
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get the platform from environment variable or set to linux/amd64 if not set
# quote the string to prevent word splitting
if [ -z "$PLATFORM" ]; then
PLATFORM="linux/amd64"
fi
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \
--progress=plain \
-t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
--push \
"$MONOREPO_ROOT"

34
docker/buildx.sh Executable file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
command -v docker >/dev/null 2>&1 || {
echo "Docker is not running. Please start Docker and try again."
exit 1
}
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get the platform from environment variable or set to linux/amd64 if not set
# quote the string to prevent word splitting
if [ -z "$PLATFORM" ]; then
PLATFORM="linux/amd64"
fi
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \
--progress=plain \
-t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT"

View File

@ -1,12 +0,0 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
cd "$MONOREPO_ROOT"
npm ci
npm run prisma:migrate-dev
npm run dev

View File

@ -1,32 +0,0 @@
name: documenso_test
services:
database:
image: postgres:15
environment:
- POSTGRES_USER=documenso
- POSTGRES_PASSWORD=password
- POSTGRES_DB=documenso
ports:
- 54322:5432
inbucket:
image: inbucket/inbucket
# ports:
# - 9000:9000
# - 2500:2500
# - 1100:1100
documenso:
build:
context: ../
dockerfile: docker/Dockerfile
depends_on:
- database
- inbucket
env_file:
- ../.env.example
environment:
- NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
ports:
- 3000:3000

View File

@ -1,19 +0,0 @@
name: documenso
services:
database:
image: postgres:15
container_name: database
environment:
- POSTGRES_USER=documenso
- POSTGRES_PASSWORD=password
- POSTGRES_DB=documenso
ports:
- 54320:5432
inbucket:
image: inbucket/inbucket
container_name: mailserver
ports:
- 9000:9000
- 2500:2500
- 1100:1100

View File

@ -1,40 +0,0 @@
services:
database:
image: postgres:15
environment:
- POSTGRES_USER=documenso
- POSTGRES_PASSWORD=password
- POSTGRES_DB=documenso
ports:
- 5432:5432
inbucket:
image: inbucket/inbucket
ports:
- 9000:9000
- 2500:2500
- 1100:1100
documenso:
image: node:18
working_dir: /app
command: ./docker/compose-entrypoint.sh
depends_on:
- database
- inbucket
environment:
- NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
- NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
- NEXTAUTH_SECRET=my-super-secure-secret
- NEXTAUTH_URL=http://localhost:3000
- SENDGRID_API_KEY=
- SMTP_MAIL_HOST=inbucket
- SMTP_MAIL_PORT=2500
- SMTP_MAIL_USER=username
- SMTP_MAIL_PASSWORD=password
- MAIL_FROM=admin@example.com
ports:
- 3000:3000
volumes:
- ../:/app

View File

@ -1,4 +1,5 @@
name: documenso-services
name: documenso-development
services:
database:
image: postgres:15

View File

@ -0,0 +1,67 @@
name: documenso-production
services:
database:
image: postgres:15
environment:
- POSTGRES_USER=${POSTGRES_USER:?err}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
volumes:
- database:/var/lib/postgresql/data
documenso:
image: documenso/documenso:latest
depends_on:
database:
condition: service_healthy
environment:
- PORT=${PORT:-3000}
- NEXTAUTH_URL=${NEXTAUTH_URL:-${NEXT_PUBLIC_WEBAPP_URL}}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err}
- NEXT_PRIVATE_ENCRYPTION_KEY=${NEXT_PRIVATE_ENCRYPTION_KEY:?err}
- NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY:?err}
- NEXT_PRIVATE_GOOGLE_CLIENT_ID=${NEXT_PRIVATE_GOOGLE_CLIENT_ID}
- NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=${NEXT_PRIVATE_GOOGLE_CLIENT_SECRET}
- NEXT_PUBLIC_WEBAPP_URL=${NEXT_PUBLIC_WEBAPP_URL:?err}
- NEXT_PUBLIC_MARKETING_URL=${NEXT_PUBLIC_MARKETING_URL:-https://documenso.com}
- NEXT_PRIVATE_DATABASE_URL=${NEXT_PRIVATE_DATABASE_URL:?err}
- NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL:-${NEXT_PRIVATE_DATABASE_URL}}
- NEXT_PUBLIC_UPLOAD_TRANSPORT=${NEXT_PUBLIC_UPLOAD_TRANSPORT:-database}
- NEXT_PRIVATE_UPLOAD_ENDPOINT=${NEXT_PRIVATE_UPLOAD_ENDPOINT}
- NEXT_PRIVATE_UPLOAD_REGION=${NEXT_PRIVATE_UPLOAD_REGION}
- NEXT_PRIVATE_UPLOAD_BUCKET=${NEXT_PRIVATE_UPLOAD_BUCKET}
- NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=${NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID}
- NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=${NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY}
- NEXT_PRIVATE_SMTP_TRANSPORT=${NEXT_PRIVATE_SMTP_TRANSPORT:?err}
- NEXT_PRIVATE_SMTP_HOST=${NEXT_PRIVATE_SMTP_HOST}
- NEXT_PRIVATE_SMTP_PORT=${NEXT_PRIVATE_SMTP_PORT}
- NEXT_PRIVATE_SMTP_USERNAME=${NEXT_PRIVATE_SMTP_USERNAME}
- NEXT_PRIVATE_SMTP_PASSWORD=${NEXT_PRIVATE_SMTP_PASSWORD}
- NEXT_PRIVATE_SMTP_APIKEY_USER=${NEXT_PRIVATE_SMTP_APIKEY_USER}
- NEXT_PRIVATE_SMTP_APIKEY=${NEXT_PRIVATE_SMTP_APIKEY}
- NEXT_PRIVATE_SMTP_SECURE=${NEXT_PRIVATE_SMTP_SECURE}
- NEXT_PRIVATE_SMTP_FROM_NAME=${NEXT_PRIVATE_SMTP_FROM_NAME:?err}
- NEXT_PRIVATE_SMTP_FROM_ADDRESS=${NEXT_PRIVATE_SMTP_FROM_ADDRESS:?err}
- NEXT_PRIVATE_RESEND_API_KEY=${NEXT_PRIVATE_RESEND_API_KEY}
- NEXT_PRIVATE_MAILCHANNELS_API_KEY=${NEXT_PRIVATE_MAILCHANNELS_API_KEY}
- NEXT_PRIVATE_MAILCHANNELS_ENDPOINT=${NEXT_PRIVATE_MAILCHANNELS_ENDPOINT}
- NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=${NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN}
- NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=${NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR}
- NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=${NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY}
- 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:

7
docker/start.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
set -x
npx prisma migrate deploy --schema ./packages/prisma/schema.prisma
node apps/web/server.js

View File

@ -0,0 +1,54 @@
name: documenso-test
services:
database:
image: postgres:15
environment:
- POSTGRES_USER=documenso
- POSTGRES_PASSWORD=password
- POSTGRES_DB=documenso
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U documenso']
interval: 1s
timeout: 5s
retries: 5
ports:
- 54322:5432
inbucket:
image: inbucket/inbucket
ports:
- 9000:9000
- 2500:2500
- 1100:1100
documenso:
build:
context: ../../
dockerfile: docker/Dockerfile
depends_on:
database:
condition: service_healthy
inbucket:
condition: service_started
env_file:
- ../../.env.example
environment:
- NEXTAUTH_SECRET=secret
- NEXT_PRIVATE_ENCRYPTION_KEY=CAFEBABE
- NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=DEADBEEF
- NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
- NEXT_PUBLIC_UPLOAD_TRANSPORT=database
- NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
- NEXT_PRIVATE_SMTP_HOST=inbucket
- NEXT_PRIVATE_SMTP_PORT=2500
- NEXT_PRIVATE_SMTP_USERNAME=documenso
- 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

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