Compare commits

...

73 Commits

Author SHA1 Message Date
Adithya Krishna
06aae9abd0 feat: updated ui items
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-28 09:42:52 +05:30
Adithya Krishna
dfb1e8a121 chore: updated styles
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-24 22:40:05 +05:30
Adithya Krishna
90b9b58afe feat: update ui
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-24 22:38:53 +05:30
Adithya Krishna
7e9efe9139 feat: update signin signup pages
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-22 22:59:36 +05:30
Adithya Krishna
767679cdc6 feat: update signin signup ui
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-22 01:57:46 +05:30
Adithya Krishna
0664de93ab feat: update marketing site
Signed-off-by: Adithya Krishna <adithya@documenso.com>
2024-02-16 17:54:23 +05:30
David Nguyen
a868ecf2d2 fix: restrict team verification tokens (#927)
## Description

Currently we're not restricting team transfer and email verification
tokens from flowing into the frontend.

This changes restricts it to only return the required information
instead of the whole data object.
2024-02-12 18:23:07 +11:00
David Nguyen
b1bb345929 fix: redirect URL preventing document flow (#925)
## Description

Currently the document redirect URL feature is preventing documents from
being created unless a redirect URL is provided.

During the document edit flow, the redirect URL is hidden in an advanced
tab with the value of an empty string, which will always fail the
current Zod validation since `optional` requires undefined to pass.

There are multiple ways to fix this, but I think this is the easiest
method where we can assume an empty string is valid.
2024-02-12 15:23:15 +11:00
David Nguyen
3a32bc62c5 feat: initial document audit logs implementation (#922)
Added initial implementation of document audit logs.
2024-02-12 12:04:53 +11:00
Adithya Krishna
4f990a7030 feat: redirect users upon signing completion (#888)
**Description:** 

- This PR adds a feature to redirect the users to a specific URL upon
signing
2024-02-09 13:31:01 +05:30
Adithya Krishna
e26debe836 Merge branch 'main' into feat/sign-redirect 2024-02-09 12:10:20 +05:30
David Nguyen
8641884515 fix: recipients with CC role not being editable (#918)
## Description

Fixed issue where setting a recipient role as CC will prevent any
further changes as it is considered as "sent" and "signed".

## Other changes

- Prevent editing document after completed
- Removed CC and Viewers from the field recipient list since they will
never be filled
- Minor UI issues

## 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 followed the project's coding style guidelines.
2024-02-09 12:37:17 +11:00
Adithya Krishna
09b5621542 Merge branch 'main' into feat/sign-redirect 2024-02-08 12:56:42 +05:30
Lucas Smith
47b8cc598c fix: add validation and error message display 2024-02-08 04:28:16 +00:00
David Nguyen
e97b9b4f1c feat: add team templates (#912) 2024-02-08 12:33:20 +11:00
Timur Ercan
e0889426bb feat: blog article why i started documenso (#914)
- New Blogarticle
- First Draft
- Input and impressions welcome
- Not Grammer fixed yet, will do that later (grammerly account broken)
2024-02-07 13:13:33 +01:00
Timur Ercan
e2a5638f50 chore: fixed 2024-02-07 13:07:22 +01:00
Timur Ercan
33ab8797a5 chore: text 2024-02-07 12:22:07 +01:00
Timur Ercan
c7e0b73c97 Merge branch 'main' into feat/why-i-started-documenso 2024-02-07 12:00:22 +01:00
Timur Ercan
b6bdbf72a7 Update apps/marketing/content/blog/why-i-started-documenso.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-02-07 12:00:12 +01:00
Timur Ercan
718f5664ac Update apps/marketing/content/blog/why-i-started-documenso.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-02-07 12:00:01 +01:00
Timur Ercan
58477e060a Update apps/marketing/content/blog/why-i-started-documenso.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-02-07 11:41:40 +01:00
Timur Ercan
2e719288ff Update apps/marketing/content/blog/why-i-started-documenso.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-02-07 11:41:32 +01:00
Timur Ercan
2431db06f5 Update apps/marketing/content/blog/why-i-started-documenso.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-02-07 11:41:14 +01:00
Timur Ercan
754e9b15f7 Update apps/marketing/content/blog/why-i-started-documenso.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-02-07 11:40:11 +01:00
David Nguyen
7d39e3d065 feat: add team feature flag (#915)
## Description

Add the ability to feature flag the teams feature via UI.

Also added minor UI changes

## 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 followed the project's coding style guidelines.
2024-02-07 21:32:44 +11:00
Adithya Krishna
7bb00c1c89 Merge branch 'main' into feat/why-i-started-documenso 2024-02-07 14:05:28 +05:30
Lucas Smith
70c58470c1 fix: update field remove logic (#916)
## Description

Fixed issue where you are prevented from removing fields for a recipient
that has multiple fields.

Example:

Recipient has 3 fields, you remove 1 and proceed to next step. In the
next step the field reappears as it was not deleted.

## 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 followed the project's coding style guidelines.
2024-02-07 16:39:54 +11:00
Sumit Bisht
bf26f2cb9d fix: empty document titles (#917)
fixes: #909
2024-02-07 16:25:39 +11:00
David Nguyen
58e2eda5e9 fix: update field remove logic 2024-02-07 14:40:15 +11:00
Timur Ercan
94ddb0252b fix: stuff 2024-02-06 19:09:27 +01:00
Timur Ercan
2f696ddd13 feat: blog article why i started documenso 2024-02-06 18:55:16 +01:00
Adithya Krishna
1dd543247e chore: update branch
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-02-06 18:07:24 +05:30
Adithya Krishna
2636d5fd16 chore: finish and clean-up redirect post signing
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-02-06 18:04:56 +05:30
rajesh
fe4345eeb9 fix: add responsive for blog preview mobile view (#906)
This Issue https://github.com/documenso/documenso/issues/904
2024-02-06 16:45:46 +11:00
David Nguyen
0c339b78b6 feat: add teams (#848)
## Description

Add support for teams which will allow users to collaborate on
documents.

Teams features allows users to:

- Create, manage and transfer teams
- Manage team members
- Manage team emails
- Manage a shared team inbox and documents

These changes do NOT include the following, which are planned for a
future release:

- Team templates
- Team API
- Search menu integration

## Testing Performed

- Added E2E tests for general team management
- Added E2E tests to validate document counts

## 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.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [X] I have followed the project's coding style guidelines.
2024-02-06 16:16:10 +11:00
Adithya Krishna
9ed16c64d8 Merge branch 'main' of https://github.com/documenso/documenso into feat/sign-redirect 2024-02-05 13:13:16 +05:30
Adithya Krishna
94e72534e0 chore: updated redirection
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-02-05 13:13:12 +05:30
Catalin Pit
c6457e75e0 feat: migration for verifying paid users (#889) 2024-02-05 14:17:01 +11:00
Sumit Bisht
f5930dc934 perf: mentioned type and size of the doc to be uploaded (#867)
explicitly mentioned "PDF" to upload, and added a toast if pdf size is
greater than 50mb

fixes: #621
2024-02-05 12:50:35 +11:00
Lucas Smith
8f3a52e1fd fix: update e2e test 2024-02-02 04:49:42 +00:00
Ashraf Chowdury
861225b7c4 fix: Prevent users from bypassing document limitations (#898)
## Description

**Fixed document limitation bypassing issues through templates.**
Previously, users could bypass document restrictions by utilizing
templates even after reaching their limitations. This fix ensures that
templates will no longer function as a workaround when users reach their
document limits.

## Changes
1. imported `useLimits` hook on `data-table-templates.tsx`
2. Disabled the 'Use Template' button when the user reaches their limit.
3. Added an Alert Component on top of the templates page to notify users
that they can't use templates anymore because they have reached their
limit.
4. Used `getServerLimits` hook on `template-router` to a condition on
the server.

## Example

![image](https://github.com/documenso/documenso/assets/87828904/275e83ea-ca7b-4b0e-83f4-ac10da9aff6a)

## Issue
Closes #883
2024-02-02 15:48:42 +11:00
Lucas Smith
7210d48b64 fix: update dockerfile to support encryption keys 2024-02-02 04:37:29 +00:00
Lucas Smith
5cf2f0a30e fix: only throw crypto key errors on server 2024-02-02 04:24:49 +00:00
Hani
7ece6ef239 feat: add recipient roles (#716)
Fixes #705

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
Co-authored-by: David Nguyen <davidngu28@gmail.com>
2024-02-02 10:45:02 +11:00
Lucas Smith
e42088a5bf feat: add user security audit logs (#884)
## Description

Adds the ability to see the events relating to the account.

Event data includes:
- Device
- IP Address
- Time
- Action

Actions are:

- Profile update
- Account linked to SSO (Example user signs in with Google after
creating a email/password account)
- Enable 2FA
- Disable 2FA
- Reset password
- Update password
- Sign out
- Sign in
- Sign in fail
- Sign in 2FA fail

## Changes

- Added audit logs
- Updated 2FA dialogs to have consistent footers
- Update `/settings/security/page` layout

## Testing Performed

Tested events:


![image](https://github.com/documenso/documenso/assets/20962767/8ab9e055-aa58-4621-86fe-24681cce6418)

More tested events:


![image](https://github.com/documenso/documenso/assets/20962767/b6b42e13-626e-4fed-8e1a-097e5324aa6d)

## Checklist

- [X] I have tested these changes locally and they work as expected.
- [X] I have followed the project's coding style guidelines.

## Additional Notes

- Not sure if we really want to record the sign out event or not
- Might want to design breadcrumbs for nested setting pages
2024-02-02 09:42:25 +11:00
Sumit Bisht
ec3ba0e922 fix: active-tab changes correctly (#897)
fixes: #890
2024-02-02 08:30:02 +11:00
Apoorv Taneja
56683aa998 fix: Added signing pad disable state while submitting form (#892)
Fixes : #891
2024-02-01 19:14:37 +11:00
Lucas Smith
39be53ace8 fix: show fields on every step while editing documents (#881)
![CleanShot 2024-01-29 at 00 51
31@2x](https://github.com/documenso/documenso/assets/55143799/d577e027-92d1-48fa-940b-1359386367c5)

![CleanShot 2024-01-29 at 00 51
39@2x](https://github.com/documenso/documenso/assets/55143799/ce2df10e-e254-4854-89a1-ba86d7b05a42)
2024-02-01 12:55:31 +11:00
Lucas Smith
7fbf124b89 fix: use div instead of rnd for preview fields 2024-02-01 01:10:50 +00:00
Adithya Krishna
f4c24fd944 feat: add a feature for redirecting users on signing
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-01-31 18:17:43 +05:30
Adithya Krishna
3541a805e5 chore: add migration file
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-01-31 18:16:07 +05:30
David Nguyen
27d8098511 fix: document count period filter (#882)
## Description

Currently the count for the documents table tabs do not display the
correct values when the period filter is applied.

## Changes Made

- Updated `getStats` to support filtering on period

## Testing Performed

- Tested to see if the documents tab count were being filtered based on
the period

## Checklist

- [X] I have tested these changes locally and they work as expected.
- [X] I have followed the project's coding style guidelines.
2024-01-31 12:40:37 +11:00
David Nguyen
ada46a5f47 feat: add auth fail logs 2024-01-31 12:27:40 +11:00
David Nguyen
1bda74b3aa fix: add cascade delete for audit logs 2024-01-30 18:37:48 +11:00
David Nguyen
9427143951 fix: remove account create log 2024-01-30 18:26:46 +11:00
David Nguyen
7e15058a3a feat: add user security audit logs 2024-01-30 17:32:20 +11:00
Adithya Krishna
620ae41fcc feat: added password validation (#469)
This PR Fixes #464
2024-01-30 14:26:47 +11:00
Ephraim Atta-Duncan
f8125aec54 feat: show fields on other sections 2024-01-30 00:09:22 +00:00
Anik Dhabal Babu
9d6ee94708 chore: add title and description to individual pages (#847)
Add Title and Description to Individual Pages.
eg:- Security | Documenso, Profile | Documenso etc.
2024-01-29 17:53:44 +11:00
Lucas Smith
f3df0d9c13 fix: add env example crypto defaults back 2024-01-29 16:24:13 +11:00
Ephraim Duncan
a3a4480b03 Merge branch 'main' into fix/show-fields-subject 2024-01-29 01:40:49 +00:00
Ephraim Atta-Duncan
4af5ce3a6b chore: remove border color for field item 2024-01-29 01:38:44 +00:00
Ephraim Atta-Duncan
4ae19a9e63 chore: tidy code 2024-01-29 00:59:08 +00:00
Ephraim Atta-Duncan
6d5fe4eea3 fix: show the fields on the document at the subject selection page 2024-01-29 00:47:11 +00:00
Ephraim Duncan
354e16901c fix: sign dialog completed title color in dark mode (#879) 2024-01-29 11:08:31 +11:00
Ephraim Duncan
09aa10dad6 chore: rewording to avoid confusion between signed and original document (#880) 2024-01-29 11:04:57 +11:00
José Lima
671fd916b5 fix: resolve conflicting z-index values btwn avatar in document list and header (#872)
## Description

This pull request solves the problem where the avatar component within
the document list has the same z-index value as the header component,
causing the avatar to be above the header. When two elements have the
same z-index value, the last one takes priority!

## Related Issue
Fixes #870 

## Changes Made

1. Increases the value of the header's `z-index` by `10` (the current
value is `50`
2024-01-27 13:16:59 +11:00
Timur Ercan
a3ddbc15e9 Feat/commodifying signing (#874) 2024-01-26 12:36:33 +01:00
Timur Ercan
c7a04c7184 Merge branch 'main' into feat/commodifying-signing 2024-01-26 12:03:33 +01:00
Timur Ercan
8619e02d04 chore: quote fix 2024-01-26 12:02:30 +01:00
Timur Ercan
91c89e8bfb chore: quote fix 2024-01-26 12:01:53 +01:00
Timur Ercan
fdeab19a7f chore: fix paragh quote break 2024-01-26 12:00:00 +01:00
360 changed files with 18492 additions and 2028 deletions

View File

@@ -5,9 +5,9 @@ NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
# Application Key for symmetric encryption and decryption
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY=""
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=""
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
# [[AUTH OPTIONAL]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
@@ -25,7 +25,7 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
@@ -74,6 +74,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
# OPTIONAL: The private key to use for DKIM signing.
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
# [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY=

View File

@@ -27,14 +27,16 @@ Tags:
# Commodifying Signing
> TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better.
> While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means.
While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means.
Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly.
## Is signing already a commodity?
> In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them.
> That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market?
That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market?
- Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume.
- Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to
@@ -68,7 +70,8 @@ Before creating a commodity, we are raising the bar of what the underlying publi
As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I:
> In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers.
> By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish.
By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish.
## Changing the Game

View File

@@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog.
While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead.
We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live).
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
Best from Hamburg\
Timur

View File

@@ -0,0 +1,68 @@
---
title: Why I started Documenso
description: I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the internet/ world more open.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-06
Tags:
- Founders
- Mission
- Open Source
---
<figure>
<MdxNextImage
src="/blog/burgers.jpeg"
width="650"
height="100"
alt="Burgers, drinks on a table between friends."
/>
<figcaption className="text-center">
Not the burger from the story. But it could be as well, the place is pretty generic.
</figcaption>
</figure>
> TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption, and wanted to help make the world/ Internet more open.
It's hard to pinpoint when I decided to start Documenso. I first uttered the word "Documenso" while sitting in a restaurant with [Felix](https://twitter.com/flxmgdnz), eating a burger and discussing what's next in late 2022. Shortly after, I sat down with a can of caffeine and started building [Documenso 0.9](https://github.com/documenso/documenso/releases/tag/0.9-developer-preview). Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side.
Looking at the personal side, I've had some time off and was actively looking for my next move. Looking back, I stumbled into my first company. Less so with the second one, but I joined my co-founders and did not develop the core concept myself. While coming up with Documenso, I was deliberately looking for a few things, based on my previous experiences:
- An entrepreneurial space that was a big enough opportunity
- A huge macro trend, lifting everything in it's space
- A mode of working that fits my flow (which, luckily for me, is pretty close to the modern startup/ tech scene)
- A more significant impact to be made than just earning lots of money (though there is nothing wrong with that)
Quick shoutout to everyone feeling even a pinch of imposter syndrome while calling themselves a founder. It was after ten years, slightly after starting Documenso, that I started doing it in my head without cringing. So cut yourself some slack. Considering how long I've been doing this, I would have earned the internal title sooner, and so do you. After grappling with my identity for a second, as is customary for founders, my decision to start this journey came quickly.
Aside from the personal dimension, I had a clear mindset of what I wanted. The criteria I describe below clicked into place one after another, in no particular order. Having experienced no market demand and a very gritty, grindy market, I was looking for something more fundamental. Something basic, infrastructure-like, with a huge demand. A growing market deeply rooted in the ever-increasing digitalization of the world.
And to be honest, I just always liked digital signature tools. It's a product that is easy enough to comprehend and build but complex and impactful enough to satisfy a hard need. It's a product you can build very product-driven since the market and domain are well understood. So when asked about what's next for me, I literally said, "Digital, um, let's say… signatures". As it turns out, my first gut feeling was spot on, but how spot on I only realized when I started researching the space. An open source document signing company happens to be the perfect intersection of all the criteria and personal preferences I described above; it's pretty amazing, actually:
- The global signing market is enormous and rapidly growing
- To put it bluntly, the signing space is vast and dominated by one outdated player. Outdated in terms of tech, pricing, and ecosystem
- The signing space is also ridiculously opaque for a space based on open web tech, open encryption tech, and open signing standards. Even by closed-source standards
- We are currently seeing a renaissance for commercial open source startups, combining venture founder financials with open source mechanics
- Rebuilding a fundamental infrastructure as open source with a meaningful scale has a profoundly transformative effect on any space
- Working in open source requires being open, cooperative, and inclusive. It also requires quite a bit of context jumping, "going with the flow," and empathy
- Apart from fixing the signing space, making Documenso successful would be another domino tile toward open source eating the world, which is great for everyone
Building a company is so complex it can't be planned out. Basing it on great fundamentals and the expected dynamics is the best founders can do, in my humble opinion. After these fundamental decisions, you are (almost) just along for the ride and need to focus on solving the "conventional" problems of starting a company the best you can. With digital signatures hitting so many points of my personal and professional checklist, this already was a great fit. What got me excited at first, though, apart from the perspective of drinking caffeine and coding, was this:
Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, two years validity, from VeriSign, I think. Apart from it being ridiculously complicated to get, it bothered me that we had basically paid $200 for what is essentially a long number someone generated. SSL wasn't even that widespread back then because it was mainly considered important for e-commerce, no wonder considering it cost so much. "Why would I encrypt a blog?". Fast forward to today, and everyone can get a free SSL cert courtesy of [Let's Encrypt](https://letsencrypt.org/) and browsers are basically blocking unencrypted sites. Mostly, it is even built into hosting platforms, so you barely even notice as a developer.
I had forgotten all about that story until I realized this is where signing is today. A global need fulfilled only by a closed ecosystem, not really state-of-the-art companies, leading to, let's call it, steep prices. I had considered Let's Encrypt a pillar of the open internet for so long that I forgot that they weren't always there. One day, someone said, let's make the internet better. Signing is another domain that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the "pre-Let's Encrypt world." Free document signing certificates via "Let's Sign" are now another to-do on the [long-term roadmap](https://documen.so/roadmap) list for the open signing ecosystem. Effecting this change in any way is a huge driver for me.
Apart from my personal gripes with the corporate certificate industry, I have always found encryption fascinating. It's such a fundamental force in society when you think about it: Secure Communication, Secure Commerce, and even [internet native, open source money (Bitcoin)](https://github.com/bitcoin/bitcoin) were created using a bit of smart math. All these examples are expressions of very fundamental human behaviors that should be enabled and protected by open infrastructures.
I never told rthis to anyone before, but since starting Documenso, I realized that I underestimated the impact and importance of open source for quite some time. When I was in University, I distantly remember my mindset of "yeah, open source is nice, but the great, commercially successful products used in the real world are built by closed companies (aka Microsoft)" _shudder_ It was never really a conscious thought, but enough that I started learning MS Silverlight before plain Javascript. It was slowly, over time, that I realized that open web standards are superior to closed ones, and even later, I understood the same holds true for all software. Open source fixes something in the economy I find hard to articulate. I did my best in [Commodifying Signing](https://documenso.com/blog/commodifying-signing).
To wrap this up, Documenso happens to be the perfect storm of market opportunity, my interests, and my passions. Creating a company in which people want to work for the long term while tackling these issues is a critical side quest of Documenso. This is not only about building the next generation of signing tech; it's also about doing our part to normalize open, healthy, efficient working cultures and tackling relevant problems.
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions, comments, thoughts or feelings.
\
Best from Hamburg\
Timur

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View File

@@ -15,7 +15,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
notFound();
}
return { title: `Documenso - ${document.title}` };
return { title: document.title };
};
const mdxComponents: MDXComponents = {

View File

@@ -18,7 +18,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
}
return {
title: `Documenso - ${blogPost.title}`,
title: {
absolute: `${blogPost.title} - Documenso Blog`,
},
description: blogPost.description,
};
};

View File

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

View File

@@ -2,9 +2,12 @@
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import backgroundPattern from '@documenso/assets/images/background-lw-2.png';
import { cn } from '@documenso/ui/lib/utils';
import { AnnouncementBar } from '@documenso/ui/primitives/announcement-bar';
import { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header';
@@ -38,10 +41,18 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
<div className="absolute -inset-0 -z-[1] opacity-100">
<Image
src={backgroundPattern}
alt="background pattern"
className="h-[2rem] w-full object-cover"
/>
</div>
<AnnouncementBar className="relative" isShown={true} />
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
<div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div>
<div className="relative max-w-screen-xl flex-1 px-4 sm:mx-auto lg:px-8">{children}</div>
<Footer className="bg-background border-muted mt-24 border-t" />
</div>

View File

@@ -1,3 +1,5 @@
import type { Metadata } from 'next';
import { z } from 'zod';
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
@@ -14,6 +16,10 @@ import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
import { TeamMembers } from './team-members';
import { OpenPageTooltip } from './tooltip';
export const metadata: Metadata = {
title: 'Open Startup',
};
export const revalidate = 3600;
export const dynamic = 'force-dynamic';

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next';
import Image from 'next/image';
import { z } from 'zod';
@@ -5,7 +6,12 @@ import { z } from 'zod';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { OSSFriendsContainer } from './container';
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
import type { TOSSFriendsSchema } from './schema';
import { ZOSSFriendsSchema } from './schema';
export const metadata: Metadata = {
title: 'OSS Friends',
};
export default async function OSSFriendsPage() {
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {

View File

@@ -1,4 +1,5 @@
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
import type { Metadata } from 'next';
import { Caveat } from 'next/font/google';
import { cn } from '@documenso/ui/lib/utils';
@@ -10,6 +11,11 @@ import { OpenBuildTemplateBento } from '~/components/(marketing)/open-build-temp
import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento';
export const revalidate = 600;
export const metadata: Metadata = {
title: {
absolute: 'Documenso - The Open Source DocuSign Alternative',
},
};
const fontCaveat = Caveat({
weight: ['500'],

View File

@@ -1,5 +1,4 @@
'use client';
import type { Metadata } from 'next';
import Link from 'next/link';
import {
@@ -12,6 +11,10 @@ import { Button } from '@documenso/ui/primitives/button';
import { PricingTable } from '~/components/(marketing)/pricing-table';
export const metadata: Metadata = {
title: 'Pricing',
};
export type PricingPageProps = {
searchParams?: {
planId?: string;

View File

@@ -85,6 +85,7 @@ export const SinglePlayerClient = () => {
setFields(
data.fields.map((field, i) => ({
id: i,
secondaryId: i.toString(),
documentId: -1,
templateId: null,
recipientId: -1,
@@ -158,6 +159,7 @@ export const SinglePlayerClient = () => {
readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
role: 'SIGNER',
};
const onFileDrop = async (file: File) => {

View File

@@ -1,5 +1,11 @@
import type { Metadata } from 'next';
import { SinglePlayerClient } from './client';
export const metadata: Metadata = {
title: 'Singleplayer',
};
export const revalidate = 0;
// !: This entire file is a hack to get around failed prerendering of

View File

@@ -18,7 +18,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative',
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:

View File

@@ -43,7 +43,7 @@ export default function NotFound() {
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="ghost"
variant="secondary"
className="w-32"
onClick={() => {
void router.back();

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 Community 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">
-80%
</span>
</Button>

View File

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

View File

@@ -74,6 +74,15 @@ export const Header = ({ className, ...props }: HeaderProps) => {
>
Sign in
</Link>
<Link
href="https://app.documenso.com/signin"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xs">
Sign up
</span>
</Link>
</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 Community 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">
-80%
</span>
</Button>
@@ -190,42 +191,32 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<Widget className="mt-12">
<strong>Documenso Supporter Pledge</strong>
<p className="w-full max-w-[70ch]">
Our mission is to create an open signing infrastructure that empowers the world,
enabling businesses to embrace openness, cooperation, and transparency. We believe
that signing, as a fundamental act, should embody these values. By offering an
open-source signing solution, we aim to make document signing accessible, transparent,
and trustworthy.
Our mission is to create an open signing infrastructure that empowers the world. We
believe openness and cooperation are the way every business should be conducted.
</p>
<p className="w-full max-w-[70ch]">
Through our platform, called Documenso, we strive to earn your trust by allowing
self-hosting and providing complete visibility into its inner workings. We value
inclusivity and foster an environment where diverse perspectives and contributions are
welcomed, even though we may not implement them all.
By creating an open source signing solution we want to bring these values to
businesses' most fundamental act: signing. Document Signing should be open and
transparent, as should all trust based products.
</p>
<p className="w-full max-w-[70ch]">
At Documenso, we envision a web-enabled future for business and contracts, and we are
committed to being the leading provider of open signing infrastructure. By combining
exceptional product design with open-source principles, we aim to deliver a robust and
well-designed application that exceeds your expectations.
We aim to earn this trust by enabling everyone to self-host Documenso and inspect its
inner workings. We openly share our source, knowledge, and progress while creating
Documenso.
</p>
<p className="w-full max-w-[70ch]">
We understand that exceptional products are born from exceptional communities, and we
invite you to join our open-source community. Your contributions, whether technical or
non-technical, will help shape the future of signing. Together, we can create a better
future for everyone.
Exceptional products are the results of exceptional communities and we strive to
create an inclusive, creative environment, open to all who choose to support our
mission. We value the inputs, contributions, and perspectives of everyone in our
community, even though we can't apply them all.
</p>
<p className="w-full max-w-[70ch]">
Today we invite you to join us on this journey: By signing this mission statement you
signal your support of Documenso's mission{' '}
<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
year.
We are building the next generation of trust software and community the way its meant
to be: Beautifully designed and open for all to join.
</p>
<div className="flex h-24 items-center">

View File

@@ -50,6 +50,10 @@ export const MENU_NAVIGATION_LINKS = [
href: 'https://app.documenso.com/signin',
text: 'Sign in',
},
{
href: 'https://app.documenso.com/signup',
text: 'Sign up',
},
];
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
@@ -104,7 +108,13 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
onClick={() => handleMenuItemClick()}
target={target}
>
{text}
{href === 'https://app.documenso.com/signup' ? (
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xl">
{text}
</span>
) : (
text
)}
</Link>
</motion.div>
))}

View File

@@ -194,7 +194,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 Community Plan</h3>
<p className="text-muted-foreground mt-2 text-xs">
with Timur Ercan & Lucas Smith from Documenso
</p>
@@ -215,7 +215,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) =>
@@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
</DialogDescription>
<SignaturePad
disabled={isSubmitting}
className="aspect-video w-full rounded-md border"
defaultValue={signatureDataUrl || ''}
onChange={setDraftSignatureDataUrl}

View File

@@ -45,6 +45,7 @@
"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"
},
@@ -53,7 +54,8 @@
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"
"@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39"
},
"overrides": {
"next-auth": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -7,9 +7,9 @@ import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { Document, User } from '@documenso/prisma/client';
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';
@@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? recipientInitials(row.original.User.name)
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (

View File

@@ -1,6 +1,6 @@
'use client';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

View File

@@ -19,7 +19,7 @@ type ComboboxProps = {
onChange: (_values: string[]) => void;
};
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
const dbRoles = Object.values(Role);
@@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
);
};
export { MultiSelectCombobox };
export { MultiSelectRoleCombobox };

View File

@@ -18,9 +18,10 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
@@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl>
<MultiSelectCombobox
<MultiSelectRoleCombobox
listValues={roles}
onChange={(values: string[]) => onChange(values)}
/>

View File

@@ -1,4 +1,5 @@
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions';
@@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
getPricesByType('individual'),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
]);
const individualPriceIds = individualPrices.map((price) => price.id);

View File

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

View File

@@ -32,6 +32,7 @@ export type EditDocumentFormProps = {
documentMeta: DocumentMeta | null;
fields: Field[];
documentData: DocumentData;
documentRootPath: string;
};
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
@@ -45,6 +46,7 @@ export const EditDocumentForm = ({
documentMeta,
user: _user,
documentData,
documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -149,7 +151,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, timezone, dateFormat } = data.meta;
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
try {
await sendDocument({
@@ -157,8 +159,9 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
timezone,
dateFormat,
timezone,
redirectUrl,
},
});
@@ -168,7 +171,7 @@ export const EditDocumentForm = ({
duration: 5000,
});
router.push('/documents');
router.push(documentRootPath);
} catch (err) {
console.error(err);
@@ -218,9 +221,9 @@ export const EditDocumentForm = ({
<AddTitleFormPartial
key={recipients.length}
documentFlow={documentFlow.title}
document={document}
recipients={recipients}
fields={fields}
document={document}
onSubmit={onAddTitleFormSubmit}
/>

View File

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

View File

@@ -10,6 +10,7 @@ import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Team } from '@documenso/prisma/client';
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
document: Document;
document: Document & {
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: Recipient[];
team?: Pick<Team, 'id' | 'url'>;
};
export const ZResendDocumentFormSchema = z.object({
@@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
export const ResendDocumentActionItem = ({
document,
recipients,
team,
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =
!isOwner ||
(!isOwner && !isCurrentTeamDocument) ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
@@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
toast({
title: 'Document re-sent',

View File

@@ -2,13 +2,14 @@
import Link from 'next/link';
import { Download, Edit, Pencil } from 'lucide-react';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@@ -37,6 +40,10 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const documentsPath = formatDocumentsPath(team?.url);
const onDownloadClick = async () => {
try {
@@ -45,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@@ -68,6 +76,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
}
return match({
isOwner,
isRecipient,
@@ -75,27 +88,48 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
isPending,
isComplete,
isSigned,
isCurrentTeamDocument,
})
.with({ isOwner: true, isDraft: true }, () => (
<Button className="w-32" asChild>
<Link href={`/documents/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
))
.with(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
<Button className="w-32" asChild>
<Link href={`${documentsPath}/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
),
)
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
</Link>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
Sign
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</Button>
))
.with({ isComplete: true }, () => (

View File

@@ -5,9 +5,11 @@ import { useState } from 'react';
import Link from 'next/link';
import {
CheckCircle,
Copy,
Download,
Edit,
EyeIcon,
Loader,
MoreHorizontal,
Pencil,
@@ -18,8 +20,9 @@ import {
import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -40,10 +43,12 @@ export type DataTableActionDropdownProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@@ -63,6 +68,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const documentsPath = formatDocumentsPath(team?.url);
const onDownloadClick = async () => {
try {
@@ -71,6 +79,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@@ -105,15 +114,35 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="mr-2 h-4 w-4" />
Sign
</Link>
</DropdownMenuItem>
{recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
View
</>
)}
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
<Link href={`/documents/${row.id}`}>
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" />
Sign
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</>
)}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
@@ -141,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuLabel>Share</DropdownMenuLabel>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
<DocumentShareButton
documentId={row.id}
@@ -171,6 +200,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
team={team}
/>
)}
</DropdownMenu>

View File

@@ -0,0 +1,63 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
type DataTableSenderFilterProps = {
teamId: number;
};
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const isMounted = useIsMounted();
const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
teamId,
});
const comboBoxOptions = (data ?? []).map((member) => ({
label: member.user.name ?? member.user.email,
value: member.user.id,
}));
const onChange = (newSenderIds: number[]) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('senderIds', newSenderIds.join(','));
if (newSenderIds.length === 0) {
params.delete('senderIds');
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
return (
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="text-muted-foreground font-normal">
<span className="text-muted-foreground/70">Sender:</span> All
</p>
}
enableClearAllButton={true}
inputPlaceholder="Search"
loading={!isMounted || isInitialLoading}
options={comboBoxOptions}
selectedValues={senderIds}
onChange={onChange}
/>
);
};

View File

@@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
Document & {
Recipient: Recipient[];
User: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'id' | 'url'> | null;
}
>;
showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'>;
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
export const DocumentsDataTable = ({
results,
showSenderColumn,
team,
}: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
@@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
id: 'sender',
header: 'Sender',
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
},
{
header: 'Recipient',
accessorKey: 'recipient',
@@ -79,8 +91,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} />
<DataTableActionButton team={team} row={row.original} />
<DataTableActionDropdown team={team} row={row.original} />
</div>
),
},
@@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: Boolean(showSenderColumn),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>

View File

@@ -0,0 +1,155 @@
import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
import { DataTableSenderFilter } from './data-table-sender-filter';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
export type DocumentsPageViewProps = {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
senderIds?: string;
};
team?: Team & { teamEmail?: TeamEmail | null };
};
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const currentTeam = team ? { id: team.id, url: team.url } : undefined;
const getStatOptions: GetStatsInput = {
user,
period,
};
if (team) {
getStatOptions.team = {
teamId: team.id,
teamEmail: team.teamEmail?.email,
senderIds,
};
}
const stats = await getStats(getStatOptions);
const results = await findDocuments({
userId: user.id,
teamId: team?.id,
status,
orderBy: {
column: 'createdAt',
direction: 'desc',
},
page,
perPage,
period,
senderIds,
});
const getTabHref = (value: typeof status) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (params.has('page')) {
params.delete('page');
}
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<UploadDocument team={currentTeam} />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="text-4xl font-semibold">Documents</h1>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DataTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{results.count > 0 && (
<DocumentsDataTable
results={results}
showSenderColumn={team !== undefined}
team={currentTeam}
/>
)}
{results.count === 0 && <EmptyDocumentState status={status} />}
</div>
</div>
);
};

View File

@@ -1,5 +1,7 @@
import { useRouter } from 'next/navigation';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
team?: Pick<Team, 'id' | 'url'>;
};
export const DuplicateDocumentDialog = ({
id,
open,
onOpenChange,
team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
teamId: team?.id,
});
const documentData = document?.documentData
@@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
}
: undefined;
const documentsPath = formatDocumentsPath(team?.url);
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`/documents/${newId}`);
router.push(`${documentsPath}/${newId}`);
toast({
title: 'Document Duplicated',
@@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
const onDuplicate = async () => {
try {
await duplicateDocument({ id });
await duplicateDocument({ id, teamId: team?.id });
} catch {
toast({
title: 'Something went wrong',

View File

@@ -1,114 +1,25 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
import { PublicProfileIntro } from './username-claim/public-profile-intro';
export type DocumentsPageProps = {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
};
searchParams?: DocumentsPageViewProps['searchParams'];
};
export const metadata: Metadata = {
title: 'Documents',
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const { user } = await getRequiredServerComponentSession();
const stats = await getStats({
user,
});
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const results = await findDocuments({
userId: user.id,
status,
orderBy: {
column: 'createdAt',
direction: 'desc',
},
page,
perPage,
period,
});
const getTabHref = (value: typeof status) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (params.has('page')) {
params.delete('page');
}
return `/documents?${params.toString()}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<UploadDocument />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<h1 className="text-4xl font-semibold">Documents</h1>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs defaultValue={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{results.count > 0 && <DocumentsDataTable results={results} />}
{results.count === 0 && <EmptyDocumentState status={status} />}
</div>
</div>
<>
<PublicProfileIntro user={user} />
<DocumentsPageView searchParams={searchParams} />
</>
);
}

View File

@@ -10,8 +10,10 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type UploadDocumentProps = {
className?: string;
team?: {
id: number;
url: string;
};
};
export const UploadDocument = ({ className }: UploadDocumentProps) => {
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
@@ -38,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
return 'You have reached your document limit.';
return team
? 'Document upload disabled due to unpaid invoices'
: 'You have reached your document limit.';
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
}
}, [remaining.documents, session?.user.emailVerified]);
}, [remaining.documents, session?.user.emailVerified, team]);
const onFileDrop = async (file: File) => {
try {
@@ -60,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
teamId: team?.id,
});
toast({
@@ -74,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
router.push(`/documents/${id}`);
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
} catch (error) {
console.error(error);
@@ -96,6 +105,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
}
};
const onFileDropRejected = () => {
toast({
title: 'Your document failed to upload.',
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000,
variant: 'destructive',
});
};
return (
<div className={cn('relative', className)}>
<DocumentDropzone
@@ -103,14 +121,17 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
/>
<div className="absolute -bottom-6 right-0">
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
{team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
</div>
{isLoading && (
@@ -119,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
</div>
)}
{remaining.documents === 0 && (
{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">

View File

@@ -0,0 +1,232 @@
'use client';
import React, { useRef, useState } from 'react';
import Image from 'next/image';
import { zodResolver } from '@hookform/resolvers/zod';
import { File } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import Check from '@documenso/assets/Check.svg';
import Lucas from '@documenso/assets/images/Lucas.png';
import Timur from '@documenso/assets/images/Timur.png';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardHeader } from '@documenso/ui/primitives/card';
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 { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = z.object({
profileURL: z.string().trim().min(1, { message: 'Please enter a valid URL slug.' }),
});
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileIntroProps = {
user: User;
};
export const PublicProfileIntro = ({ user }: PublicProfileIntroProps) => {
const form = useForm<TPublicProfileFormSchema>({
values: {
profileURL: user.profileURL || '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
const textRef = useRef<HTMLSpanElement>(null);
const { toast } = useToast();
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSaving = form.formState.isSubmitting;
const isProfileURLClaimed = user.profileURL ? false : true;
const [showClaimingDialog, setShowClaimingDialog] = useState(isProfileURLClaimed);
const [showClaimedDialog, setShowClaimedDialog] = useState(true);
const onFormSubmit = async ({ profileURL }: TPublicProfileFormSchema) => {
try {
await updatePublicProfile({
profileURL,
});
setShowClaimingDialog(false);
setShowClaimedDialog(true);
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to save your details. Please try again later.',
});
}
}
};
return (
<>
<Dialog open={showClaimingDialog} onOpenChange={setShowClaimingDialog}>
<DialogContent position="center" className="pb-4">
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-2xl">
Introducing public profile!
</DialogTitle>
<DialogDescription className="text-muted-foreground/60 text-center text-sm">
Reserve your Documenso public profile username
</DialogDescription>
</DialogHeader>
<Card className="relative flex flex-col items-center border-none bg-gray-50 px-6 py-6 pb-0 shadow-none">
<code className="rounded-md border-2 border-gray-200 px-1 py-1 text-sm">
<span>documenso.com/u/timur</span>
</code>
<Avatar className="dark:border-border mt-2 h-20 w-20 border-2 border-solid border-white bg-white">
<AvatarImage className="AvatarImage" src={Timur.src} alt="Timur" />
<AvatarFallback className="text-xs text-gray-400">Timur</AvatarFallback>
</Avatar>
<div className="flex flex-row gap-x-2">
Timur Ercan <Image alt="Check" src={Check} />
</div>
<span className="text-muted-foreground/60 text-center">
Hey Im Timur <br /> Pick any of the following agreements below and start signing to
get started
</span>
<Card className="bg mt-2 w-full items-center shadow-none">
<CardHeader className="p-4 text-gray-500">Documents</CardHeader>
<hr className="mb-2" />
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-2">
<File className="ml-3" />
<div className="flex flex-col">
<span className="text-md">NDA.pdf</span>
<span className="text-muted-foreground mt-0.5 text-xs">
Like to discuss about my work?
</span>
</div>
</div>
<Button className="mr-3" variant="default">
Sign
</Button>
</div>
</Card>
<div
className="fade-overlay bg-black-100 absolute bottom-0 h-1/4 w-full"
style={{
background: `linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, white 75%, white 100%)`,
}}
></div>
</Card>
<Form {...form}>
<form className={cn('flex w-full flex-col')} onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSaving}>
<FormField
control={form.control}
name="profileURL"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile URL</FormLabel>
<FormControl>
<>
<Input type="text" className="mb-2 mt-2" {...field} />
<div className="mt-2">
<code className="bg-muted rounded-md px-1 py-1 text-sm">
<span ref={textRef} id="textToCopy">
documenso.com/u/
</span>
</code>
</div>
</>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<div className="mt-4 text-center">
<Button type="submit" loading={isSaving}>
Claim your username
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
<Dialog open={false} onOpenChange={setShowClaimedDialog}>
<DialogContent position="center" className="pb-4">
<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 feature is launched
</DialogDescription>
</DialogHeader>
<Card className="relative px-6 py-6">
<Card className="flex flex-col items-center px-6 py-6">
<code className="bg-muted rounded-md px-1 py-1 text-sm">
<span>documenso.com/u/lucas</span>
</code>
<Avatar className="dark:border-border mt-2 h-12 w-12 border-2 border-solid border-white">
<AvatarImage className="AvatarImage" src={Lucas.src} alt="Lucas" />
<AvatarFallback className="text-xs text-gray-400">Timur</AvatarFallback>
</Avatar>
<div className="flex flex-row gap-x-2">
Lucas Smith <Image alt="Check" src={Check} />
</div>
<div className="flex inline-flex h-full w-full flex-col items-center justify-center gap-3 py-2">
<Skeleton className="w-75 h-4 animate-none rounded-full" />
<Skeleton className="w-50 h-4 animate-none rounded-full" />
</div>
</Card>
<Card className="mt-2 items-center">
<CardHeader className="p-2">Documents</CardHeader>
<hr className="mb-2" />
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-2">
<File className="ml-3" />
<div className="flex flex-col">
<span className="text-md">NDA.pdf</span>
<span className="text-muted-foregroun mt-0.5 text-xs">
Like to discuss about my work?
</span>
</div>
</div>
<Button className="mr-3" variant="default">
Sign
</Button>
</div>
</Card>
</Card>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
@@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
redirect('/signin');
}
const { user } = await getRequiredServerComponentSession();
const [{ user }, teams] = await Promise.all([
getRequiredServerComponentSession(),
getTeams({ userId: session.user.id }),
]);
return (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Header user={user} />
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
export const BillingPortalButton = () => {
export type BillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
};
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
};
return (
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
<Button
{...buttonProps}
onClick={async () => handleFetchPortalUrl()}
loading={isFetchingPortalUrl}
>
Manage Subscription
</Button>
);

View File

@@ -1,11 +1,13 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { type Stripe } from '@documenso/lib/server-only/stripe';
@@ -17,6 +19,10 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button';
export const metadata: Metadata = {
title: 'Billing',
};
export default async function BillingSettingsPage() {
let { user } = await getRequiredServerComponentSession();
@@ -31,23 +37,23 @@ export default async function BillingSettingsPage() {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, individualPrices] = await Promise.all([
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ type: 'individual' }),
getPricesByType('individual'),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
]);
const individualPriceIds = individualPrices.map(({ id }) => id);
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
individualPriceIds.includes(priceId),
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
communityPlanPriceIds.includes(priceId),
);
const subscription =
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
individualUserSubscriptions[0];
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
communityPlanUserSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(

View File

@@ -1,17 +1,20 @@
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { ProfileForm } from '~/components/forms/profile';
export const metadata: Metadata = {
title: 'Profile',
};
export default async function ProfileSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-2xl font-semibold">Profile</h3>
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
<hr className="my-4" />
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<ProfileForm user={user} className="max-w-xl" />
</div>

View File

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

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
import type { Metadata } from 'next';
import Link from 'next/link';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
export const metadata: Metadata = {
title: 'Public Profile',
};
export default function PublicProfilePage() {
return (
<>
<SettingsHeader
title="Public profile"
subtitle=""
className="max-w-xl"
titleChildren={
<Link
href="#"
className="bg-primary dark:text-background ml-2 rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
>
Coming soon!
</Link>
}
/>
</>
);
}

View File

@@ -0,0 +1,26 @@
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 = {
title: 'Security activity',
};
export default function SettingsSecurityActivityPage() {
return (
<div>
<SettingsHeader
title="Security activity"
subtitle="View all recent security activity related to your account."
titleChildren={<ActivityPageBackButton />}
/>
<hr className="my-4" />
<UserSecurityActivityDataTable />
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { LocaleDate } from '~/components/formatter/locale-date';
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const UserSecurityActivityDataTable = () => {
const parser = new UAParser();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.profile.findUserSecurityAuditLogs.useQuery(
{
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<DataTable
columns={[
{
header: 'Date',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'Device',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
let output = result.os.name;
if (!output) {
return 'N/A';
}
if (result.os.version) {
output += ` (${result.os.version})`;
}
return output;
},
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
onClearFilters={() => router.push(pathname ?? '/')}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading && isInitialLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination table={table} />}
</DataTable>
);
};

View File

@@ -1,62 +1,100 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
import { PasswordForm } from '~/components/forms/password';
export const metadata: Metadata = {
title: 'Security',
};
export default async function SecuritySettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-2xl font-semibold">Security</h3>
<p className="text-muted-foreground mt-2 text-sm">
Here you can manage your password and security settings.
</p>
<hr className="my-4" />
<SettingsHeader
title="Security"
subtitle="Here you can manage your password and security settings."
/>
{user.identityProvider === 'DOCUMENSO' ? (
<div>
<PasswordForm user={user} className="max-w-xl" />
<PasswordForm user={user} />
<hr className="mb-4 mt-8" />
<hr className="border-border/50 mt-6" />
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Two factor authentication</AlertTitle>
<p className="text-muted-foreground mt-2 text-sm">
Add and manage your two factor security settings to add an extra layer of security to
your account!
</p>
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Two-factor methods</h5>
<AlertDescription className="mr-4">
Create one-time passwords that serve as a secondary authentication method for
confirming your identity when requested during the sign-in process.
</AlertDescription>
</div>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
</Alert>
{user.twoFactorEnabled && (
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Recovery methods</h5>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the
event that you lose access to your authenticator app.
</AlertDescription>
</div>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
</Alert>
)}
</div>
) : (
<div>
<h4 className="text-lg font-medium">
<Alert className="p-6" variant="neutral">
<AlertTitle>
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</h4>
<p className="text-muted-foreground mt-2 text-sm">
</AlertTitle>
<AlertDescription>
To update your password, enable two-factor authentication, and manage other security
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
settings.
</p>
</div>
</AlertDescription>
</Alert>
)}
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>Recent activity</AlertTitle>
<AlertDescription className="mr-2">
View all recent security activity related to your account.
</AlertDescription>
</div>
<Button asChild>
<Link href="/settings/security/activity">View activity</Link>
</Button>
</Alert>
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AcceptTeamInvitationButtonProps = {
teamId: number;
};
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
const { toast } = useToast();
const {
mutateAsync: acceptTeamInvitation,
isLoading,
isSuccess,
} = trpc.team.acceptTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Accepted team invitation',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to join this team at this time.',
});
},
});
return (
<Button
onClick={async () => acceptTeamInvitation({ teamId })}
loading={isLoading}
disabled={isLoading || isSuccess}
>
Accept
</Button>
);
};

View File

@@ -0,0 +1,39 @@
'use client';
import { AnimatePresence } from 'framer-motion';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { CreateTeamDialog } from '~/components/(teams)/dialogs/create-team-dialog';
import { UserSettingsTeamsPageDataTable } from '~/components/(teams)/tables/user-settings-teams-page-data-table';
import { TeamEmailUsage } from './team-email-usage';
import { TeamInvitations } from './team-invitations';
export default function TeamsSettingsPage() {
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
return (
<div>
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
<CreateTeamDialog />
</SettingsHeader>
<UserSettingsTeamsPageDataTable />
<div className="mt-8 space-y-8">
<AnimatePresence>
{teamEmail && (
<AnimateGenericFadeInOut>
<TeamEmailUsage teamEmail={teamEmail} />
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
<TeamInvitations />
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState } from 'react';
import type { TeamEmail } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamEmailUsageProps = {
teamEmail: TeamEmail & { team: { name: string; url: string } };
};
export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'You have successfully revoked access.',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description:
'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
});
},
});
return (
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
<div>
<AlertTitle className="mb-0">Team Email</AlertTitle>
<AlertDescription>
<p>
Your email is currently being used by team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
).
</p>
<p className="mt-1">They have permission on your behalf to:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Display your name and email in documents</li>
<li>View all documents sent to your account</li>
</ul>
</AlertDescription>
</div>
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">Revoke access</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription className="mt-4">
You are about to revoke access for team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
use your email.
</DialogDescription>
</DialogHeader>
<fieldset disabled={isDeletingTeamEmail}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingTeamEmail}
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
>
Revoke
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
</Alert>
);
};

View File

@@ -0,0 +1,83 @@
'use client';
import { AnimatePresence } from 'framer-motion';
import { BellIcon } from 'lucide-react';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
export const TeamInvitations = () => {
const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
return (
<AnimatePresence>
{data && data.length > 0 && !isInitialLoading && (
<AnimateGenericFadeInOut>
<Alert variant="secondary">
<div className="flex h-full flex-row items-center p-2">
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
<AlertDescription className="mr-2">
You have <strong>{data.length}</strong> pending team invitation
{data.length > 1 ? 's' : ''}.
</AlertDescription>
<Dialog>
<DialogTrigger asChild>
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
View invites
</button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Pending invitations</DialogTitle>
<DialogDescription className="mt-4">
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
</DialogDescription>
</DialogHeader>
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
{data.map((invitation) => (
<li key={invitation.teamId}>
<AvatarWithText
className="w-full max-w-none py-4"
avatarFallback={invitation.team.name.slice(0, 1)}
primaryText={
<span className="text-foreground/80 font-semibold">
{invitation.team.name}
</span>
}
secondaryText={formatTeamUrl(invitation.team.url)}
rightSideComponent={
<div className="ml-auto">
<AcceptTeamInvitationButton teamId={invitation.team.id} />
</div>
}
/>
</li>
))}
</ul>
</DialogContent>
</Dialog>
</div>
</Alert>
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
);
};

View File

@@ -28,6 +28,7 @@ export type EditTemplateFormProps = {
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
templateRootPath: string;
};
type EditTemplateStep = 'signers' | 'fields';
@@ -40,6 +41,7 @@ export const EditTemplateForm = ({
fields,
user: _user,
documentData,
templateRootPath,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -98,7 +100,7 @@ export const EditTemplateForm = ({
duration: 5000,
});
router.push('/templates');
router.push(templateRootPath);
} catch (err) {
toast({
title: 'Error',

View File

@@ -1,81 +1,10 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import type { TemplatePageViewProps } from './template-page-view';
import { TemplatePageView } from './template-page-view';
import { ChevronLeft } from 'lucide-react';
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageProps = {
params: {
id: string;
};
};
export default async function TemplatePage({ params }: TemplatePageProps) {
const { id } = params;
const templateId = Number(id);
if (!templateId || Number.isNaN(templateId)) {
redirect('/documents');
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect('/documents');
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
/>
</div>
);
export default function TemplatePage({ params }: TemplatePageProps) {
return <TemplatePageView params={params} />;
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageViewProps = {
params: {
id: string;
};
team?: Team;
};
export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) => {
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
redirect(templateRootPath);
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect(templateRootPath);
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
templateRootPath={templateRootPath}
/>
</div>
);
};

View File

@@ -21,9 +21,15 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog';
export type DataTableActionDropdownProps = {
row: Template;
templateRootPath: string;
teamId?: number;
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
export const DataTableActionDropdown = ({
row,
templateRootPath,
teamId,
}: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -34,6 +40,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
}
const isOwner = row.userId === session.user.id;
const isTeamTemplate = row.teamId === teamId;
return (
<DropdownMenu>
@@ -44,20 +51,25 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/templates/${row.id}`}>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link href={`${templateRootPath}/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDuplicateDialogOpen(true)}
>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
@@ -65,6 +77,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DuplicateTemplateDialog
id={row.id}
teamId={teamId}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@@ -2,13 +2,16 @@
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader, Plus } from 'lucide-react';
import { AlertTriangle, Loader, Plus } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -25,6 +28,9 @@ type TemplatesDataTableProps = {
perPage: number;
page: number;
totalPages: number;
documentRootPath: string;
templateRootPath: string;
teamId?: number;
};
export const TemplatesDataTable = ({
@@ -32,10 +38,15 @@ export const TemplatesDataTable = ({
perPage,
page,
totalPages,
documentRootPath,
templateRootPath,
teamId,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const { remaining } = useLimits();
const router = useRouter();
const { toast } = useToast();
@@ -65,7 +76,7 @@ export const TemplatesDataTable = ({
duration: 5000,
});
router.push(`/documents/${id}`);
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
title: 'Error',
@@ -77,6 +88,19 @@ export const TemplatesDataTable = ({
return (
<div className="relative">
{remaining.documents === 0 && (
<Alert variant="warning" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Document Limit Exceeded!</AlertTitle>
<AlertDescription className="mt-2">
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" href="/settings/billing">
Upgrade your account to continue!
</Link>
</AlertDescription>
</Alert>
)}
<DataTable
columns={[
{
@@ -102,7 +126,7 @@ export const TemplatesDataTable = ({
return (
<div className="flex items-center gap-x-4">
<Button
disabled={isRowLoading}
disabled={isRowLoading || remaining.documents === 0}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
@@ -113,7 +137,12 @@ export const TemplatesDataTable = ({
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown row={row.original} />
<DataTableActionDropdown
row={row.original}
teamId={teamId}
templateRootPath={templateRootPath}
/>
</div>
);
},

View File

@@ -35,20 +35,15 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
onOpenChange(false);
},
});
const onDeleteTemplate = async () => {
try {
await deleteTemplate({ id });
} catch {
onError: () => {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
@@ -63,20 +58,18 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
variant="secondary"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
Delete
</Button>
</div>
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -14,12 +14,14 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
@@ -40,22 +42,15 @@ export const DuplicateTemplateDialog = ({
onOpenChange(false);
},
onError: () => {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
},
});
const onDuplicate = async () => {
try {
await duplicateTemplate({
templateId: id,
});
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
@@ -66,20 +61,27 @@ export const DuplicateTemplateDialog = ({
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
disabled={isLoading}
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
Duplicate
</Button>
</div>
<Button
type="button"
loading={isLoading}
onClick={async () =>
duplicateTemplate({
templateId: id,
teamId,
})
}
>
Duplicate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -43,8 +43,14 @@ const ZCreateTemplateFormSchema = z.object({
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
export const NewTemplateDialog = () => {
type NewTemplateDialogProps = {
teamId?: number;
templateRootPath: string;
};
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
@@ -99,6 +105,7 @@ export const NewTemplateDialog = () => {
});
const { id } = await createTemplate({
teamId,
title: values.name ? values.name : file.name,
templateDocumentDataId,
});
@@ -112,7 +119,7 @@ export const NewTemplateDialog = () => {
setShowNewTemplateDialog(false);
void router.push(`/templates/${id}`);
router.push(`${templateRootPath}/${id}`);
} catch {
toast({
title: 'Something went wrong',

View File

@@ -1,52 +1,18 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
import type { Metadata } from 'next';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
import { TemplatesPageView } from './templates-page-view';
import type { TemplatesPageViewProps } from './templates-page-view';
type TemplatesPageProps = {
searchParams?: {
page?: number;
perPage?: number;
};
searchParams?: TemplatesPageViewProps['searchParams'];
};
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
export const metadata: Metadata = {
title: 'Templates',
};
const { templates, totalPages } = await getTemplates({
userId: user.id,
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
<div>
<NewTemplateDialog />
</div>
</div>
<div className="relative">
{templates.length > 0 ? (
<TemplatesDataTable
templates={templates}
page={page}
perPage={perPage}
totalPages={totalPages}
/>
) : (
<EmptyTemplateState />
)}
</div>
</div>
);
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
return <TemplatesPageView searchParams={searchParams} />;
}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
export type TemplatesPageViewProps = {
searchParams?: {
page?: number;
perPage?: number;
};
team?: Team;
};
export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const documentRootPath = formatDocumentsPath(team?.url);
const templateRootPath = formatTemplatesPath(team?.url);
const { templates, totalPages } = await findTemplates({
userId: user.id,
teamId: team?.id,
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="truncate text-2xl font-semibold md:text-3xl">Templates</h1>
</div>
<div>
<NewTemplateDialog templateRootPath={templateRootPath} teamId={team?.id} />
</div>
</div>
<div className="relative mt-5">
{templates.length > 0 ? (
<TemplatesDataTable
templates={templates}
page={page}
perPage={perPage}
totalPages={totalPages}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
teamId={team?.id}
/>
) : (
<EmptyTemplateState />
)}
</div>
</div>
);
};

View File

@@ -30,7 +30,7 @@ export const DocumentPreviewButton = ({
{...props}
>
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
View Document
View Original Document
</Button>
<DocumentDialog documentData={documentData} open={showDialog} onOpenChange={setShowDialog} />

View File

@@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@@ -94,7 +94,10 @@ export default async function CompletedSigningPage({
))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed
You have
{recipient.role === RecipientRole.SIGNER && ' signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '}
<span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2>
@@ -128,7 +131,7 @@ export default async function CompletedSigningPage({
/>
) : (
<DocumentPreviewButton
className="flex-1"
className="text-[11px]"
title="Signatures will appear once the document has been completed"
documentData={documentData}
/>

View File

@@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
@@ -26,9 +26,10 @@ export type SigningFormProps = {
document: Document;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
};
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
@@ -74,7 +75,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
timestamp: new Date().toISOString(),
});
router.push(`/sign/${recipient.token}/complete`);
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
};
return (
@@ -96,73 +97,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
<fieldset
disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<div
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && 'View Document'}
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
</h3>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
{recipient.role === RecipientRole.VIEWER ? (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please mark as viewed to complete
</p>
<hr className="border-border mb-8 mt-4" />
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4" />
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
/>
</div>
</div>
</>
) : (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<div>
<Label htmlFor="Signature">Signature</Label>
<hr className="border-border mb-8 mt-4" />
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</CardContent>
</Card>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
/>
</div>
</div>
</>
)}
</div>
</fieldset>
</form>

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';
@@ -12,10 +14,16 @@ export type SigningLayoutProps = {
export default async function SigningLayout({ children }: SigningLayoutProps) {
const { user, session } = await getServerComponentSession();
let teams: GetTeamsResponse = [];
if (user && session) {
teams = await getTeams({ userId: user.id });
}
return (
<NextAuthProvider session={session}>
<div className="min-h-screen">
{user && <AuthenticatedHeader user={user} />}
{user && <AuthenticatedHeader user={user} teams={teams} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
</div>

View File

@@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="py-4">
<div>
<Label htmlFor="signature">Full Name</Label>
<Input

View File

@@ -1,3 +1,4 @@
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
@@ -8,13 +9,13 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@@ -40,24 +41,26 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const requestHeaders = Object.fromEntries(headers().entries());
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token }).catch(() => null),
viewedDocument({ token, requestMetadata }).catch(() => null),
]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document;
const { documentData, documentMeta } = document;
const { user } = await getServerComponentSession();
@@ -65,7 +68,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
) {
redirect(`/sign/${token}/complete`);
documentMeta?.redirectUrl
? redirect(documentMeta.redirectUrl)
: redirect(`/sign/${token}/complete`);
}
if (documentMeta?.password) {
@@ -110,7 +115,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to sign this document.
{document.User.name} ({document.User.email}) has invited you to{' '}
{recipient.role === RecipientRole.VIEWER && 'view'}
{recipient.role === RecipientRole.SIGNER && 'sign'}
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
</p>
</div>
@@ -130,7 +138,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm document={document} recipient={recipient} fields={fields} />
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import type { Document, Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -17,6 +18,7 @@ export type SignDialogProps = {
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
role: RecipientRole;
};
export const SignDialog = ({
@@ -25,6 +27,7 @@ export const SignDialog = ({
fields,
fieldsValidated,
onSignatureComplete,
role,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
@@ -45,9 +48,18 @@ export const SignDialog = ({
</DialogTrigger>
<DialogContent>
<div className="text-center">
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
{role === RecipientRole.SIGNER && 'Sign Document'}
{role === RecipientRole.APPROVER && 'Approve Document'}
</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
You are about to finish signing "{truncatedTitle}". Are you sure?
{role === RecipientRole.VIEWER &&
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.SIGNER &&
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.APPROVER &&
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
</div>
</div>
@@ -71,7 +83,9 @@ export const SignDialog = ({
loading={isSubmitting}
onClick={onSignatureComplete}
>
Sign
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
{role === RecipientRole.SIGNER && 'Sign'}
{role === RecipientRole.APPROVER && 'Approve'}
</Button>
</div>
</DialogFooter>

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
type ErrorProps = {
error: Error & { digest?: string };
};
export default function ErrorPage({ error }: ErrorProps) {
const router = useRouter();
let errorMessage = 'Unknown error';
let errorDetails = '';
if (error.message === AppErrorCode.UNAUTHORIZED) {
errorMessage = 'Unauthorized';
errorDetails = 'You are not authorized to view this page.';
}
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">{errorMessage}</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
<p className="text-muted-foreground mt-4 text-sm">{errorDetails}</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="secondary"
className="w-32"
onClick={() => {
void router.back();
}}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
<Button asChild>
<Link href="/settings/teams">View teams</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { match } from 'ts-pattern';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import type { TeamMemberRole } from '@documenso/prisma/client';
import { type Subscription, SubscriptionStatus } 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,
DialogFooter,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LayoutBillingBannerProps = {
subscription: Subscription;
teamId: number;
userRole: TeamMemberRole;
};
export const LayoutBillingBanner = ({
subscription,
teamId,
userRole,
}: LayoutBillingBannerProps) => {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: createBillingPortal, isLoading } =
trpc.team.createBillingPortal.useMutation();
const handleCreatePortal = async () => {
try {
const sessionUrl = await createBillingPortal({ teamId });
window.open(sessionUrl, '_blank');
setIsOpen(false);
} catch (err) {
toast({
title: 'Something went wrong',
description:
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.',
variant: 'destructive',
duration: 10000,
});
}
};
if (subscription.status === SubscriptionStatus.ACTIVE) {
return null;
}
return (
<>
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
subscription.status === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground':
subscription.status === SubscriptionStatus.INACTIVE,
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
{match(subscription.status)
.with(SubscriptionStatus.PAST_DUE, () => 'Payment overdue')
.with(SubscriptionStatus.INACTIVE, () => 'Teams restricted')
.exhaustive()}
</div>
<Button
variant="ghost"
className={cn({
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
subscription.status === SubscriptionStatus.PAST_DUE,
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
subscription.status === SubscriptionStatus.INACTIVE,
})}
disabled={isLoading}
onClick={() => setIsOpen(true)}
size="sm"
>
Resolve
</Button>
</div>
</div>
<Dialog open={isOpen} onOpenChange={(value) => !isLoading && setIsOpen(value)}>
<DialogContent>
<DialogTitle>Payment overdue</DialogTitle>
{match(subscription.status)
.with(SubscriptionStatus.PAST_DUE, () => (
<DialogDescription>
Your payment for teams is overdue. Please settle the payment to avoid any service
disruptions.
</DialogDescription>
))
.with(SubscriptionStatus.INACTIVE, () => (
<DialogDescription>
Due to an unpaid invoice, your team has been restricted. Please settle the payment
to restore full access to your team.
</DialogDescription>
))
.otherwise(() => null)}
{canExecuteTeamAction('MANAGE_BILLING', userRole) && (
<DialogFooter>
<Button loading={isLoading} onClick={handleCreatePortal}>
Resolve payment
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { RedirectType, redirect } from 'next/navigation';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { Header } from '~/components/(dashboard)/layout/header';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
import { LayoutBillingBanner } from './layout-billing-banner';
export type AuthenticatedTeamsLayoutProps = {
children: React.ReactNode;
params: {
teamUrl: string;
};
};
export default async function AuthenticatedTeamsLayout({
children,
params,
}: AuthenticatedTeamsLayoutProps) {
const { session, user } = await getServerComponentSession();
if (!session || !user) {
redirect('/signin');
}
const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
getTeams({ userId: user.id }),
getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }),
]);
if (getTeamPromise.status === 'rejected') {
redirect('/documents', RedirectType.replace);
}
const team = getTeamPromise.value;
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
return (
<NextAuthProvider session={session}>
<LimitsProvider teamId={team.id}>
{team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
<LayoutBillingBanner
subscription={team.subscription}
teamId={team.id}
userRole={team.currentTeamMember.role}
/>
)}
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus />
</LimitsProvider>
</NextAuthProvider>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export default function NotFound() {
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">404 Team not found</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
<p className="text-muted-foreground mt-4 text-sm">
The team you are looking for may have been removed, renamed or may have never existed.
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button variant="secondary" asChild className="w-32">
<Link href="/settings/teams">
<ChevronLeft className="mr-2 h-4 w-4" />
Go Back
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { DateTime } from 'luxon';
import type Stripe from 'stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table';
import { TeamBillingPortalButton } from '~/components/(teams)/team-billing-portal-button';
export type TeamsSettingsBillingPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl });
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
let teamSubscription: Stripe.Subscription | null = null;
if (team.subscription) {
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
}
const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
if (!subscription) {
return 'No payment required';
}
const numberOfSeats = subscription.items.data[0].quantity ?? 0;
const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member';
const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
'LLL dd, yyyy',
);
return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`;
};
return (
<div>
<SettingsHeader title="Billing" subtitle="Your subscription is currently active." />
<Card gradient className="shadow-sm">
<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'}
</p>
<p className="text-muted-foreground mt-0.5">
{formatTeamSubscriptionDetails(teamSubscription)}
</p>
</div>
{teamSubscription && (
<div
title={
canManageBilling
? 'Manage team subscription.'
: 'You must be an admin of this team to manage billing.'
}
>
<TeamBillingPortalButton teamId={team.id} />
</div>
)}
</CardContent>
</Card>
<section className="mt-6">
<TeamBillingInvoicesDataTable teamId={team.id} />
</section>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { notFound } from 'next/navigation';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
export type TeamSettingsLayoutProps = {
children: React.ReactNode;
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsLayout({
children,
params: { teamUrl },
}: TeamSettingsLayoutProps) {
const session = await getRequiredServerComponentSession();
try {
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw new Error(AppErrorCode.UNAUTHORIZED);
}
} catch (e) {
const error = AppError.parseError(e);
if (error.code === 'P2025') {
notFound();
}
throw e;
}
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Team Settings</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<DesktopNav className="hidden md:col-span-3 md:flex" />
<MobileNav className="col-span-12 mb-8 md:hidden" />
<div className="col-span-12 md:col-span-9">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { InviteTeamMembersDialog } from '~/components/(teams)/dialogs/invite-team-member-dialog';
import { TeamsMemberPageDataTable } from '~/components/(teams)/tables/teams-member-page-data-table';
export type TeamsSettingsMembersPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
return (
<div>
<SettingsHeader title="Members" subtitle="Manage the members or invite new members.">
<InviteTeamMembersDialog
teamId={team.id}
currentUserTeamRole={team.currentTeamMember.role}
/>
</SettingsHeader>
<TeamsMemberPageDataTable
currentUserTeamRole={team.currentTeamMember.role}
teamId={team.id}
teamName={team.name}
teamOwnerUserId={team.ownerUserId}
/>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { CheckCircle2, Clock } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-dialog';
import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog';
import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog';
import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
import { TeamEmailDropdown } from './team-email-dropdown';
import { TeamTransferStatus } from './team-transfer-status';
export type TeamsSettingsPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
const isTransferVerificationExpired =
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
return (
<div>
<SettingsHeader title="Team Profile" subtitle="Here you can edit your team's details." />
<TeamTransferStatus
className="mb-4"
currentUserTeamRole={team.currentTeamMember.role}
teamId={team.id}
transferVerification={team.transferVerification}
/>
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(team.teamEmail || team.emailVerification) && (
<Alert className="p-6" variant="neutral">
<AlertTitle>Team email</AlertTitle>
<AlertDescription className="mr-2">
You can view documents associated with this email and use this identity when sending
documents.
</AlertDescription>
<hr className="border-border/50 mt-2" />
<div className="flex flex-row items-center justify-between pt-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={extractInitials(
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
)}
primaryText={
<span className="text-foreground/80 text-sm font-semibold">
{team.teamEmail?.name || team.emailVerification?.name}
</span>
}
secondaryText={
<span className="text-sm">
{team.teamEmail?.email || team.emailVerification?.email}
</span>
}
/>
<div className="flex flex-row items-center pr-2">
<div className="text-muted-foreground mr-4 flex flex-row items-center text-sm xl:mr-8">
{match({
teamEmail: team.teamEmail,
emailVerification: team.emailVerification,
})
.with({ teamEmail: P.not(null) }, () => (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
Active
</>
))
.with(
{
emailVerification: P.when(
(emailVerification) =>
emailVerification && emailVerification?.expiresAt < new Date(),
),
},
() => (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
Expired
</>
),
)
.with({ emailVerification: P.not(null) }, () => (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
Awaiting email confirmation
</>
))
.otherwise(() => null)}
</div>
<TeamEmailDropdown team={team} />
</div>
</div>
</Alert>
)}
{!team.teamEmail && !team.emailVerification && (
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Team email</AlertTitle>
<AlertDescription className="mr-2">
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
{/* Feature not available yet. */}
{/* <li>Display this name and email when sending documents</li> */}
{/* <li>View documents associated with this email</li> */}
<span>View documents associated with this email</span>
</ul>
</AlertDescription>
</div>
<AddTeamEmailDialog teamId={team.id} />
</Alert>
)}
{team.ownerUserId === session.user.id && (
<>
{isTransferVerificationExpired && (
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Transfer team</AlertTitle>
<AlertDescription className="mr-2">
Transfer the ownership of the team to another team member.
</AlertDescription>
</div>
<TransferTeamDialog
ownerUserId={team.ownerUserId}
teamId={team.id}
teamName={team.name}
/>
</Alert>
)}
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Delete team</AlertTitle>
<AlertDescription className="mr-2">
This team, and any associated data excluding billing invoices will be permanently
deleted.
</AlertDescription>
</div>
<DeleteTeamDialog teamId={team.id} teamName={team.name} />
</Alert>
</>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import { useRouter } from 'next/navigation';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog';
export type TeamsSettingsPageProps = {
team: Awaited<ReturnType<typeof getTeamByUrl>>;
};
export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } =
trpc.team.resendTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Email verification has been resent',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to resend verification at this time. Please try again.',
});
},
});
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Team email has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove team email at this time. Please try again.',
});
},
});
const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
trpc.team.deleteTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Email verification has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove email verification at this time. Please try again.',
});
},
});
const onRemove = async () => {
if (team.teamEmail) {
await deleteTeamEmail({ teamId: team.id });
}
if (team.emailVerification) {
await deleteTeamEmailVerification({ teamId: team.id });
}
router.refresh();
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!team.teamEmail && team.emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<Loader className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Resend verification
</DropdownMenuItem>
)}
{team.teamEmail && (
<UpdateTeamEmailDialog
teamEmail={team.teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
}
/>
)}
<DropdownMenuItem
disabled={isDeletingTeamEmail || isDeletingTeamEmailVerification}
onClick={async () => onRemove()}
>
<X className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,115 @@
'use client';
import { useRouter } from 'next/navigation';
import { AnimatePresence } from 'framer-motion';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamTransferStatusProps = {
className?: string;
currentUserTeamRole: TeamMemberRole;
teamId: number;
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
};
export const TeamTransferStatus = ({
className,
currentUserTeamRole,
teamId,
transferVerification,
}: TeamTransferStatusProps) => {
const router = useRouter();
const { toast } = useToast();
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
const { mutateAsync: deleteTeamTransferRequest, isLoading } =
trpc.team.deleteTeamTransferRequest.useMutation({
onSuccess: () => {
if (!isExpired) {
toast({
title: 'Success',
description: 'The team transfer invitation has been successfully deleted.',
duration: 5000,
});
}
router.refresh();
},
onError: () => {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.',
});
},
});
return (
<AnimatePresence>
{transferVerification && (
<AnimateGenericFadeInOut>
<Alert
variant={isExpired ? 'destructive' : 'warning'}
className={cn(
'flex flex-col justify-between p-6 sm:flex-row sm:items-center',
className,
)}
>
<div>
<AlertTitle>
{isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
</AlertTitle>
<AlertDescription>
{isExpired ? (
<p className="text-sm">
The team transfer request to <strong>{transferVerification.name}</strong> has
expired.
</p>
) : (
<section className="text-sm">
<p>
A request to transfer the ownership of this team has been sent to{' '}
<strong>
{transferVerification.name} ({transferVerification.email})
</strong>
</p>
<p>
If they accept this request, the team will be transferred to their account.
</p>
</section>
)}
</AlertDescription>
</div>
{canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
<Button
onClick={async () => deleteTeamTransferRequest({ teamId })}
loading={isLoading}
variant={isExpired ? 'destructive' : 'ghost'}
className={cn('ml-auto', {
'hover:bg-transparent hover:text-blue-800': !isExpired,
})}
>
{isExpired ? 'Close' : 'Cancel'}
</Button>
)}
</Alert>
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view';
import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view';
type TeamTemplatePageProps = {
params: TemplatePageViewProps['params'] & {
teamUrl: string;
};
};
export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <TemplatePageView params={params} team={team} />;
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view';
import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view';
type TeamTemplatesPageProps = {
searchParams?: TemplatesPageViewProps['searchParams'];
params: {
teamUrl: string;
};
};
export default async function TeamTemplatesPage({
searchParams = {},
params,
}: TeamTemplatesPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <TemplatesPageView searchParams={searchParams} team={team} />;
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type CheckEmailLayoutProps = {
children: React.ReactNode;
};
export default function CheckEmailLayout({ children }: CheckEmailLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<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">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Forgot password',
};
export default function ForgotPasswordPage() {
return (
<div>

View File

@@ -0,0 +1,34 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type ForgotPasswordLayoutProps = {
children: React.ReactNode;
};
export default function ForgotPasswordLayout({ children }: ForgotPasswordLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-44">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center bg-contain opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<div className="relative flex w-full max-w-md items-center gap-x-24">
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}

View File

@@ -1,11 +1,16 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
export const metadata: Metadata = {
title: 'Forgot Password',
};
export default function ForgotPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Forgot your password?</h1>
<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

View File

@@ -1,27 +0,0 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
type UnauthenticatedLayoutProps = {
children: React.ReactNode;
};
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">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<div className="w-full">{children}</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type ResetPasswordLayoutProps = {
children: React.ReactNode;
};
export default function ResetPasswordLayout({ children }: ResetPasswordLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-56">
<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">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}

View File

@@ -1,11 +1,16 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Reset Password',
};
export default function ResetPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
<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

View File

@@ -0,0 +1,34 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type SignInLayoutProps = {
children: React.ReactNode;
};
export default function SignInLayout({ children }: SignInLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-[7.2rem]">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center bg-contain opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="flex min-h-screen flex-col overflow-hidden dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<div className="relative flex w-full max-w-md items-center gap-x-24">
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}

View File

@@ -1,37 +1,56 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignInForm } from '~/components/forms/signin';
export default function SignInPage() {
export const metadata: Metadata = {
title: 'Sign In',
};
type SignInPageProps = {
searchParams: {
email?: string;
};
};
export default function SignInPage({ searchParams }: SignInPageProps) {
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
if (!email && rawEmail) {
redirect('/signin');
}
return (
<div>
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
<>
<div>
<h1 className="text-3xl 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" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
{process.env.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/60 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>
</div>
<hr className="my-4" />
<SignInForm
className="mt-0"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
{process.env.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>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type SignUpLayoutProps = {
children: React.ReactNode;
};
export default function SignUpLayout({ children }: SignUpLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main
className="bg-sand-100 scale-90 items-center justify-center px-4 md:h-20 lg:mx-28 lg:px-8"
style={{ height: 'calc(100vh - 80px)' }}
>
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">{children}</div>
</main>
</>
);
}

View File

@@ -1,32 +1,35 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
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';
export default function SignUpPage() {
export const metadata: Metadata = {
title: 'Sign Up',
};
type SignUpPageProps = {
searchParams: {
email?: string;
};
};
export default function SignUpPage({ searchParams }: SignUpPageProps) {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin');
}
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
if (!email && rawEmail) {
redirect('/signup');
}
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" 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>
<>
<SignUpForm className="mt-1" initialEmail={email || undefined} isGoogleSSOEnabled={true} />
</>
);
}

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