Compare commits

...

172 Commits

Author SHA1 Message Date
60a623fafd v1.12.0-rc.1 2025-06-07 00:56:40 +10:00
6059b79a8e fix: type error 2025-06-07 00:56:33 +10:00
c73d61955b v1.12.0-rc.0 2025-06-07 00:47:49 +10:00
7c3ca72359 fix: track uninserted fields for multisign 2025-06-07 00:44:41 +10:00
55c8632620 feat: password reauthentication for documents and recipients (#1827)
Adds password reauthentication to our existing reauth providers,
additionally swaps from an exclusive provider to an inclusive type where
multiple methods can be selected to offer a this or that experience.
2025-06-07 00:27:19 +10:00
ce66da0055 feat: multisign embedding (#1823)
Adds the ability to use a multisign embedding for cases where multiple
documents need to be signed in a convenient manner.
2025-06-05 12:58:52 +10:00
695ed418e2 fix: documents failing to seal (#1821)
During our field rework that makes fields appear
more accurately between signing and the completed pdf we swapped to
using text fields. Unfortunately as part of that we dropped using the
Noto font for the text field causing ANSI encoding issues when
encountering certain characters.

This change restores the font and handles a nasty issue we had with our
form flattening reverting our selected font.
2025-06-04 23:29:36 +10:00
93aece9644 chore: dependency updates (#1808) 2025-05-22 14:30:22 +10:00
abd4fddf31 chore: test reo integration (#1806)
---
name: Pull Request
about: Submit changes to the project for review and inclusion
---

## Description

Experimental Short-Term Reo Integration
2025-05-21 15:24:46 +02:00
44bc769e60 v1.11.1 2025-05-20 22:37:46 +10:00
c8f80f7be0 fix: reverse original document logic for api endpoint 2025-05-20 22:37:17 +10:00
8540f24de0 v1.11.0 2025-05-19 15:44:10 +10:00
67203d4bd7 fix: show powered by logic (#1801)
Previous powered by display logic was incorrect, likely due to a merge
conflict.
2025-05-19 14:31:24 +10:00
9d1e638f0f fix: pending tooltip click triggers field (#1800)
Makes it so clicking on the pending field tooltip will trigger the
underlying field it refers to on click if the field can be found within
the DOM.
2025-05-19 10:27:13 +10:00
bd64ad9fef fix: improve multiselect for webhook triggers (#1795)
Replaces https://github.com/documenso/documenso/pull/1660 with the same
code but targeting our main branch.

## Demo

![CleanShot 2025-02-18 at 18 01
05](https://github.com/user-attachments/assets/5afeab95-1a80-4d54-b845-b32cb2e33266)
2025-05-15 13:01:45 +10:00
99b0ad574e feat: bulk add fields (#1683)
## Demo

![CleanShot 2025-03-04 at 02 17
47](https://github.com/user-attachments/assets/2cffaee3-9933-49e9-bdab-eadfd4c35030)

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2025-05-14 19:35:32 +00:00
9594e1fee8 chore: minor ui fixes (#1793) 2025-05-14 20:08:03 +10:00
5e3a2b8f76 fix: allow prefilling date field (#1794)
Allows the prefilling of date fields when creating a document from a
template.

Current implementation is super dirty and should be replaced asap.
2025-05-14 20:06:53 +10:00
f928503a33 chore: update dropdown icons (#1790)
### Before

![CleanShot 2025-05-12 at 11 11
05@2x](https://github.com/user-attachments/assets/af2a60bf-9676-405d-8c3d-e6b2256b53ae)

### After

![CleanShot 2025-05-12 at 11 10
25@2x](https://github.com/user-attachments/assets/aec67e9c-f0f2-4b0d-9baa-7aa327680cf1)
2025-05-14 16:44:13 +10:00
c389670785 fix: trigger webhook for duplicated documents (#1789) 2025-05-14 16:43:31 +10:00
99ad2eb645 fix: allow download of original document via api (#1788) 2025-05-14 08:22:11 +10:00
2f48679b0b fix: make lang cookie httpOnly (#1783) 2025-05-08 15:59:43 +10:00
e40c5d9d24 v1.10.3 2025-05-03 09:23:25 +10:00
ab323f149f fix: resolve issue with uploading templates 2025-05-03 09:23:17 +10:00
bf1c1ff9dc v1.10.2 2025-05-03 08:11:27 +10:00
516e237966 fix: resolve issue with uploading templates 2025-05-03 08:09:44 +10:00
ac7d24eb12 v1.10.1 2025-05-03 07:39:19 +10:00
0931c472a7 fix: resolve issue with uploading templates 2025-05-03 07:38:48 +10:00
8c9dd5e372 v1.10.0 2025-05-02 12:03:08 +10:00
e108da546d fix: incorrect data for postMessage 2025-05-02 10:50:13 +10:00
17370749b4 feat: add folders (#1711) 2025-05-02 02:46:59 +10:00
12ada567f5 feat: embed authoring part two (#1768) 2025-05-01 23:32:56 +10:00
bdb0b0ea88 feat: certificate qrcode (#1755)
Adds document access tokens and QR code functionality to enable secure
document sharing via URLs. It includes a new document access page that
allows viewing and downloading documents through tokenized links.
2025-04-28 11:30:09 +10:00
6a41a37bd4 feat: download original documents (#1742)
## Preview
![CleanShot 2025-04-10 at 14 26
11@2x](https://github.com/user-attachments/assets/d4984d85-ab40-4d38-8d5c-a1085bde21a2)
2025-04-25 22:44:03 +10:00
d78cfec00e fix: branding logos (#1759) 2025-04-24 16:15:06 +10:00
f0dcf7e9bf fix: signing volume query (#1753)
This pull request updates the implementation of the admin leaderboard,
enhancing data handling and improving type safety. It introduces clearer
differentiation between users and teams, adds additional fields to track
more relevant information, and refactors the querying logic to optimize
performance and maintainability.
2025-04-24 16:14:38 +10:00
6540291055 feat: migrate webhook execution to background jobs (#1694) 2025-04-24 06:00:53 +00:00
193325717d fix: rework fields (#1697)
Rework:
- Field styling to improve visibility
- Field insertions, better alignment, centering and overflows

## Changes

General changes:

- Set default text alignment to left if no meta found
- Reduce borders and rings around fields to allow smaller fields
- Removed lots of redundant duplicated code surrounding field rendering
- Make fields more consistent across viewing, editing and signing
- Add more transparency to fields to allow users to see under fields
- No more optional/required/etc colors when signing, required fields
will be highlighted as orange when form is "validating"

Highlighted internal changes:

- Utilize native PDF fields to insert text, instead of drawing text 
- Change font auto scaling to only apply to when the height overflows
AND no custom font is set

⚠️ Multiline changes:

Multi line is enabled for a field under these conditions

1. Field content exceeds field width
2. Field includes a new line
3. Field type is TEXT

## [BEFORE] Field UI Signing 


![image](https://github.com/user-attachments/assets/ea002743-faeb-477c-a239-3ed240b54f55)

## [AFTER] Field UI Signing 


![image](https://github.com/user-attachments/assets/0f8eb252-4cf3-4d96-8d4f-cd085881b78c)

## [BEFORE] Signing a checkbox


![image](https://github.com/user-attachments/assets/4567d745-e1da-4202-a758-5d3c178c930e)

![image](https://github.com/user-attachments/assets/c25068e7-fe80-40f5-b63a-e8a0d4b38b6c)

## [AFTER] Signing a checkbox


![image](https://github.com/user-attachments/assets/effa5e3d-385a-4c35-bc8a-405386dd27d6)

![image](https://github.com/user-attachments/assets/64be34a9-0b32-424d-9264-15361c03eca5)

## [BEFORE] What a 2nd recipient sees once someone else signed a
document


![image](https://github.com/user-attachments/assets/21c21ae2-fc62-4ccc-880a-46aab012aa70)

## [AFTER] What a 2nd recipient sees once someone else signed a document


![image](https://github.com/user-attachments/assets/ae51677b-f1d5-4008-a7fd-756533166542)

## **[BEFORE]** Inserting fields


![image](https://github.com/user-attachments/assets/1a8bb8da-9a15-4deb-bc28-eb349414465c)

## **[AFTER]** Inserting fields


![image](https://github.com/user-attachments/assets/c52c5238-9836-45aa-b8a4-bc24a3462f40)

## Overflows, multilines and field alignments testing

Debugging borders:
- Red border = The original field placement without any modifications
- Blue border = The available space to overflow

### Single line overflows and field alignments 

This is left aligned fields, overflow will always go to the end of the
page and will not wrap


![image](https://github.com/user-attachments/assets/47003658-783e-4f9c-adbf-c4686804d98f)

This is center aligned fields, the max width is the closest edge to the
page * 2


![image](https://github.com/user-attachments/assets/05a38093-75d6-4600-bae2-21ecff63e115)

This is right aligned text, the width will extend all the way to the
left hand side of the page


![image](https://github.com/user-attachments/assets/6a9d84a8-4166-4626-9fb3-1577fac2571e)

### Multiline line overflows and field alignments 

These are text fields that can be overflowed


![image](https://github.com/user-attachments/assets/f7b5456e-2c49-48b2-8d4c-ab1dc3401644)

Another example of left aligned text overflows with more text


![image](https://github.com/user-attachments/assets/3db6b35e-4c8d-4ffe-8036-0da760d9c167)
2025-04-23 21:40:42 +10:00
b94645a451 fix: optional fields being required in direct links (#1752) 2025-04-21 16:34:29 +10:00
7e6704faae chore: update tests 2025-04-21 16:23:50 +10:00
cf17fc61bc chore: update tests 2025-04-21 16:07:19 +10:00
6df8b3aac8 chore: update ci 2025-04-21 14:29:40 +10:00
fdb31772db chore: update tests 2025-04-21 14:13:12 +10:00
a3dfd81870 chore: update playwright config 2025-04-21 13:27:19 +10:00
755ef697ba chore: update playwright config 2025-04-21 13:03:29 +10:00
37cc41d713 fix: skip immediate expiration presign test 2025-04-21 12:41:38 +10:00
dd2ef3a657 v1.10.0-rc.5 2025-04-17 23:01:43 +10:00
435b3ca4f8 chore: remove legacy document update route (#1751)
Remove deprecated route
2025-04-17 16:36:10 +10:00
278cd8a9de fix: always show ip and useragent in certificate 2025-04-17 12:55:03 +10:00
f1526315f5 feat: limit free teams platform plan (#1673)
This pull request removes the `id` field from
`IsDocumentPlatformOptions` in `is-document-platform.ts` and updates the
billing logic in `create-team.ts`: platform plan users create their
first team free, but pay for subsequent teams; non-platform users need
an active team subscription if billing is enabled.
2025-04-15 21:32:15 +10:00
353a7e8e0d fix: dynamic route for team transfer (#1730)
fix: dynamic route handling for /team/verify/transfer/:token
2025-04-15 21:30:44 +10:00
34b2504268 chore: husky (#1706) 2025-04-15 21:29:03 +10:00
566abda36b chore: update render build command (#1748) 2025-04-15 19:06:06 +10:00
9121a062b3 chore: add docs for authoring 2025-04-14 11:31:54 +10:00
e613e0e347 feat: support embedded authoring for creation (#1741)
Adds support for creating documents and templates
using our embed components.

Support is super primitive at the moment and is being polished.
2025-04-11 00:20:39 +10:00
95aae52fa4 chore: add translations (#1715)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-04-10 12:24:07 +10:00
5958f38719 chore: set the default value on the top (#1734) 2025-04-08 23:35:32 +10:00
419bc02171 docs: prefill fields (#1688) 2025-04-04 00:03:37 +11:00
5e4956f3a2 fix: zero month addition (#1733)
- Add zero month at the begining of each metric on the open page
2025-04-01 11:12:41 +00:00
da71613c9f v1.10.0-rc.4 2025-03-31 20:02:22 +11:00
4d6efe091e fix: pass document meta to readonly field component (#1737)
## Description

Previously we weren't passing the DocumentMeta to our readonly field
component which is used for displaying completed fields by other
recipients.

Due to this dates that were not using the default format were displaying
as invalid date adding confusion to the signing process.

## Related Issue

Reported via support email.

## Changes Made

- Pass the document meta to the readonly field component.
- Support showing completed fields within the embedding UI.

## Testing Performed

- Manual testing
2025-03-31 17:14:56 +11:00
7e6ac4db40 fix: direct template redirects (#1727) 2025-03-28 14:45:54 +11:00
a87af910c7 v1.10.0-rc.2 2025-03-28 01:50:59 +11:00
e37b005d7f chore: update dockerfile 2025-03-28 01:28:49 +11:00
73f8518b47 chore: update tests 2025-03-28 01:21:48 +11:00
ac3deb113e chore: update ci 2025-03-27 22:49:59 +11:00
c82388c40a fix: remove console.log embed document completed (#1723) 2025-03-25 16:36:52 +02:00
31be548939 fix: duplicate webhook calls on document complete (#1721)
Fix webhooks being sent twice due to duplicate frontend calls

Updated the assistant confirmation dialog so the next signer is always
visible (if dictate is enabled). Because if the form is invalid (due to
no name) there is no visual queue that the form is invalid (since it's
hidden)

## Notes

Didn't bother to remove the weird assistants form since it currently
works for now


![image](https://github.com/user-attachments/assets/47910fec-e05e-486a-a61d-16078d948893)

## Tests

- Currently running locally
- Tested webhooks via network tab and via webhook.site
2025-03-25 21:59:13 +11:00
063fd32f18 feat: add signature configurations (#1710)
Add ability to enable or disable allowed signature types:
- Drawn
- Typed
- Uploaded

**Tabbed style signature dialog**

![image](https://github.com/user-attachments/assets/a816fab6-b071-42a5-bb5c-6d4a2572431e)

**Document settings**

![image](https://github.com/user-attachments/assets/f0c1bff1-6be1-4c87-b384-1666fa25d7a6)

**Team preferences**

![image](https://github.com/user-attachments/assets/8767b05e-1463-4087-8672-f3f43d8b0f2c)

- Add multiselect to select allowed signatures in document and templates
settings tab
- Add multiselect to select allowed signatures in teams preferences
- Removed "Enable typed signatures" from document/template edit page
- Refactored signature pad to use tabs instead of an all in one
signature pad

Added E2E tests to check settings are applied correctly for documents
and templates
2025-03-24 17:13:11 +11:00
231f51bd1f v1.10.0-rc.1 2025-03-22 17:34:33 +11:00
a8de8368a2 fix: hide powered by on certificate for platform documents 2025-03-22 12:04:08 +11:00
7dd331addf fix: allow blank rejection reasons 2025-03-22 12:01:18 +11:00
c6743a7cec v1.10.0-rc.0 2025-03-22 03:23:23 +11:00
efbc097191 fix: unblock last signer when using dictation 2025-03-22 02:34:12 +11:00
f1525991dc feat: dictate next signer (#1719)
Adds next recipient dictation functionality to document signing flow,
allowing assistants and signers to update the next recipient's
information during the signing process.

## Related Issue

N/A

## Changes Made

- Added form handling for next recipient dictation in signing dialogs
- Implemented UI for updating next recipient information
- Added e2e tests covering dictation scenarios:
  - Regular signing with dictation enabled
  - Assistant role with dictation
  - Parallel signing flow
  - Disabled dictation state

## Testing Performed

- Added comprehensive e2e tests covering:
  - Sequential signing with dictation
  - Assistant role dictation
  - Parallel signing without dictation
  - Form validation and state management
- Tested on Chrome and Firefox
- Verified recipient state updates in database
2025-03-21 13:27:04 +11:00
fb173e4d0e chore: update docker build scripts 2025-03-20 10:52:33 +11:00
d422ffa873 chore: add terms and privacy policy link (#1707) 2025-03-19 19:29:09 +11:00
55b7697316 chore: update d script in package.json (#1703)
Add the `translate:compile` command to the `d` script.
2025-03-14 16:15:57 +11:00
67bbb6c6f4 fix: autosigning fields with direct links (#1696)
## Description

The changes in `sign-direct-template.tsx` automatically fill in field
values for text, number, and dropdown fields when default values are
present or if the fields are read-only. In `checkbox-field.tsx`, the
changes fix the checkbox signing by checking if the validation is met
and improving how it saves or removes checkbox choices.

## Testing Performed

I tested the code locally with a variety of documents/fields.

## Checklist

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

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.
2025-03-13 11:18:01 +02:00
63a4bab0fe feat: better document rejection (#1702)
Improves the existing document rejection process by actually marking a
document as completed cancelling further actions.

## Related Issue

N/A

## Changes Made

- Added a new rejection status for documents
- Updated a million areas that check for document completion
- Updated email sending, so rejection is confirmed for the rejecting
recipient while other recipients are notified that the document is now
cancelled.

## Testing Performed

- Ran the testing suite to ensure there are no regressions.
- Performed manual testing of current core flows.
2025-03-13 15:08:57 +11:00
9f17c1e48e fix: adjust desktop nav search button width and spacing (#1699) 2025-03-13 10:52:01 +11:00
91ae818213 fix: missing prefillfields property from the api v2 documentation (#1700) 2025-03-12 22:54:58 +11:00
a0ace803cf fix: admin signing page crash 2025-03-12 16:53:09 +11:00
b3db3be8e9 fix: signing field disabled when pointer is out of canvas (#1652) 2025-03-12 16:44:21 +11:00
44cdbeecb4 fix: improve layout and truncate document information in logs page (#1656) 2025-03-12 16:31:03 +11:00
Tom
7214965c0c chore: update French translations (#1679) 2025-03-12 16:22:06 +11:00
8d6bf91d12 fix: persist theme cookie for a much longer time (#1693) 2025-03-12 16:09:37 +11:00
fec078081b fix: correct signer deletion (#1596) 2025-03-12 16:05:45 +11:00
c646afcd97 fix: tests 2025-03-09 15:10:19 +11:00
63d990ce8d fix: optional fields in embeds (#1691) 2025-03-09 14:41:17 +11:00
aa7d6b28a4 docs: Update documentation to match reality. colorPrimary, colorBackground,… (#1666)
Update documentation to match reality. colorPrimary, colorBackground,
and borderRadius do not exist according to the schema:
280251cfdd/packages/react/src/css-vars.ts
2025-03-09 14:38:51 +11:00
b990532633 fix: remove refresh on focus 2025-03-08 15:30:13 +11:00
65be37514f fix: prefill fields (#1689)
Users can now selectively choose which properties to pre-fill for each
field - from just a label to all available properties.
2025-03-07 09:09:15 +11:00
0df29fce36 fix: invalid request body (#1686)
Fix the invalid request body so the webhooks work again.
2025-03-06 19:47:24 +11:00
ba5b7ce480 feat: hide signature ui when theres no signature field (#1676) 2025-03-06 19:47:02 +11:00
422770a8c7 feat: allow fields prefill when generating a document from a template (#1615)
This change allows API users to pre-fill fields with values by
passing the data in the request body. Example body for V2 API endpoint
`/api/v2-beta/template/use`:

```json
{
    "templateId": 1,
    "recipients": [
        {
            "id": 1,
            "email": "signer1@mail.com",
            "name": "Signer 1"
        },
        {
            "id": 2,
            "email": "signer2@mail.com",
            "name": "Signer 2"
        }
    ],
    "prefillValues": [
        {
            "id": 14,
            "fieldMeta": {
                "type": "text",
                "label": "my label",
                "placeholder": "text placeholder test",
                "text": "auto-sign value",
                "characterLimit": 25,
                "textAlign": "right",
                "fontSize": 94,
                "required": true
            }
        },
        {
            "id": 15,
            "fieldMeta": {
                "type": "radio",
                "label": "radio label",
                "placeholder": "new radio placeholder",
                "required": false,
                "readOnly": true,
                "values": [
                    {
                        "id": 2,
                        "checked": true,
                        "value": "radio val 1"
                    },
                    {
                        "id": 3,
                        "checked": false,
                        "value": "radio val 2"
                    }
                ]
            }
        },
        {
            "id": 16,
            "fieldMeta": {
                "type": "dropdown",
                "label": "dropdown label",
                "placeholder": "DD placeholder",
                "required": false,
                "readOnly": false,
                "values": [
                    {
                        "value": "option 1"
                    },
                    {
                        "value": "option 2"
                    },
                    {
                        "value": "option 3"
                    }
                ],
                "defaultValue": "option 2"
            }
        }
    ],
    "distributeDocument": false,
    "customDocumentDataId": ""
}
```
2025-03-06 19:45:33 +11:00
083a706373 fix: duplex and 2fa refresh 2025-03-04 11:41:38 +11:00
db326cb4a9 fix: posthog reverse proxy 2025-03-04 10:48:19 +11:00
d664f571d6 fix: posthog reverse proxy 2025-03-04 10:46:59 +11:00
7c38970ee8 fix: update error logging 2025-03-04 01:41:39 +11:00
e08d62c844 fix: remove invalid prisma zod schemas 2025-03-04 01:20:13 +11:00
25bb6ffe77 fix: imports 2025-03-03 14:49:28 +11:00
e79d762710 chore: add label for checkbox and radio fields (#1607) 2025-03-03 13:46:29 +11:00
d970976299 fix: remove auto-expand in embeddding 2025-02-28 14:46:15 +11:00
3dce814ab2 fix: stripe price fetch (#1677)
Currently Stripe prices search is omitting a price for an unknown
reason.

Changed our fetch logic to use `list` instead of `search` allows us to
work around the issue.

It's unknown on the performance impact of using `list` vs `search`
2025-02-28 14:44:06 +11:00
ad520bb032 fix: remove oauth from embeds 2025-02-27 14:08:59 +11:00
596d30e2e5 fix: remove lazy pdf loader 2025-02-26 21:48:06 +11:00
6474b4a524 fix: add preferred team middleware 2025-02-26 19:42:42 +11:00
5b4db51051 fix: react-pdf canvas build 2025-02-26 18:39:21 +11:00
cf58c80e31 fix: handle empty field meta for checkboxes 2025-02-26 15:30:51 +11:00
11dbb8873e docs: add the v2 api staging base url (#1671) 2025-02-26 15:30:32 +11:00
bc7907271b fix: unbreak build for docker 2025-02-25 21:46:51 +11:00
9b376d34cf feat: add stripe dev cli 2025-02-25 21:22:28 +11:00
deea99d865 feat: search by externalId 2025-02-25 20:07:47 +11:00
3328074f51 fix: early adopters can use platform features 2025-02-25 20:07:40 +11:00
5e69665e00 fix: rr7 github build 2025-02-25 16:52:10 +11:00
c1c7cfaf8b chore: cleanup 2025-02-25 16:37:36 +11:00
7e8955b89c fix: add posthog error monitor 2025-02-25 15:14:45 +11:00
cedd5e87b1 chore: update API documentation 2025-02-25 02:36:08 +11:00
5255e8671f chore: refactor pdf worker loader 2025-02-24 21:47:06 +11:00
d4c1bad407 fix: add default oauth user url 2025-02-23 18:49:22 +11:00
01dccb7916 chore: flattern routes 2025-02-21 15:53:23 +11:00
483d7caef7 feat: allow document rejection in embeds (#1662) 2025-02-21 01:27:03 +11:00
139bc265c7 fix: migrate billing to RR7 2025-02-21 01:16:23 +11:00
991ce5ff46 fix: update teams API tokens logic 2025-02-21 00:34:50 +11:00
7728c8641c fix: share opengraph 2025-02-20 15:38:06 +11:00
50a41d0799 fix: pdf viewer and embeds 2025-02-20 15:06:36 +11:00
250381fec8 fix: billing 2025-02-20 12:17:55 +11:00
d2f3d24542 chore: update docs 2025-02-19 22:36:17 +11:00
ec07092bf6 fix: session refresh 2025-02-19 22:29:30 +11:00
63e2ef0abf fix: static caching 2025-02-19 21:35:35 +11:00
90ce52164c chore: add password tests 2025-02-19 18:41:53 +11:00
ac30654913 fix: add auth session lifetime 2025-02-19 18:04:36 +11:00
24f3ecd94f fix: remove marketing url 2025-02-19 16:45:54 +11:00
a319ea0f5e fix: add public profiles tests 2025-02-19 16:07:04 +11:00
5ce2bae39d fix: resolve internal pdf translations 2025-02-19 14:43:35 +11:00
5d86e84217 fix: prepare auth migration (#1648)
Add schema session migration in preparation for auth migration.
2025-02-18 15:19:42 +11:00
79e26a9a46 fix: remove session migration 2025-02-18 15:19:39 +11:00
dd602a7e1c fix: themes 2025-02-18 15:17:13 +11:00
fb16214dc5 chore: add asssitant role to the docs (#1638) 2025-02-17 23:28:00 +11:00
5fc724b247 fix: rework sessions 2025-02-17 22:46:36 +11:00
1ed1cb0773 chore: refactor sessions 2025-02-16 00:44:01 +11:00
8d5fafec27 feat: add static cache 2025-02-15 00:08:36 +11:00
0f6f236e0c fix: firefox fouc 2025-02-14 23:02:45 +11:00
e518985833 fix: migrate 2fa to custom auth 2025-02-14 22:00:55 +11:00
595e901bc2 fix: make auth migration more flexible 2025-02-14 19:22:11 +11:00
df8ea09021 fix: add oidc env variables 2025-02-14 18:11:54 +11:00
180656978b feat: add themes 2025-02-14 17:50:23 +11:00
28f5177064 fix: dialogs with search params 2025-02-14 16:14:02 +11:00
31de86e425 feat: add oidc 2025-02-14 16:01:16 +11:00
113ab293bb chore: make all the docker stuff work 2025-02-14 14:53:01 +11:00
1c4878e526 fix: documentation build 2025-02-13 21:21:51 +11:00
92db4d68db fix: cleanup env variables 2025-02-13 20:56:44 +11:00
7379391f92 fix: migrate translations 2025-02-13 20:24:27 +11:00
ebc2b00067 fix: add sign up hook 2025-02-13 20:21:23 +11:00
87dcdd44cd chore: update local seed data (#1622)
Add multiple example documents, pending documents, and templates for
both admin and example users
2025-02-13 19:50:05 +11:00
b205f7e5f3 fix: typed signature not working (#1635)
The `typedSignatureEnabled` prop was removed from the `SignatureField`
component, which broke the typed signature meaning that nobody could
sign documents by typing their signature.
2025-02-13 19:47:38 +11:00
c5d5355cf7 fix: assistant mode breaks for number fields 2025-02-13 19:46:14 +11:00
5fac29a07f fix: add css targets for embeds 2025-02-13 19:45:54 +11:00
1aaacab6ca fix: temp field label/text truncation (#1565)
TEMP: Fix the truncation of the field label/text.
2025-02-13 19:43:35 +11:00
06076c1809 fix: unable to check on the checkbox field (#1593)
This change prevents race conditions between state updates and API
operations by updating local state immediately before making async
calls.
2025-02-13 19:42:08 +11:00
c0ae68c28b feat: assistant role (#1588)
Introduces the ability for users with the **Assistant** role to prefill
fields on behalf of other signers. Assistants can fill in various field
types such as text, checkboxes, dates, and more, streamlining the
document preparation process before it reaches the final signers.
2025-02-13 19:37:34 +11:00
3e106c1a2d chore: api v2 docs (#1620)
chore update docs for api v2 announce
2025-02-13 18:49:37 +11:00
741639ee78 fix: improve move to team display logic 2025-02-13 18:49:03 +11:00
0b3638c42c feat: add Polish and Italian (#1618) 2025-02-13 18:48:37 +11:00
0f50110853 fix: create global settings on team creation (#1601)
The global team settings weren't created when creating a new team.

## Changes Made

The global team settings are now created when a new team is created.
2025-02-13 18:47:59 +11:00
b0f8c83134 chore: add cancelled webhook event (#1608) 2025-02-13 18:47:43 +11:00
c9e8a32471 feat: bulk send templates via csv (#1578)
Implements a bulk send feature allowing users to upload a CSV file to
create multiple documents from a template. Includes CSV template
generation, background processing, and email notifications.
2025-02-13 18:44:29 +11:00
84b193d99c fix: tidy document invite email render logic (#1597)
Updates one of our confusing ternaries to use `ts-pattern` for rendering
the conditional blocks making it easy to follow the logic occurring.
2025-02-13 18:34:38 +11:00
b03c5ab1a7 fix: admin leaderboard query sorting (#1548) 2025-02-13 18:32:38 +11:00
9db42accf3 feat: add text align option to fields (#1610)
Adds the ability to align text to the left, center or right for relevant
fields.

Previously text was always centered which can be less desirable.

See attached debug document which has left, center and right text
alignments set for fields.

<img width="614" alt="image"
src="https://github.com/user-attachments/assets/361a030e-813d-458b-9c7a-ff4c9fa5e33c"
/>

N/A

- Added text align option
- Update the insert in pdf method to support different alignments
- Added a debug mode to field insertion

- Ran manual tests using the debug mode
2025-02-13 18:28:33 +11:00
383b5f78f0 feat: migrate nextjs to rr7 2025-02-13 14:10:38 +11:00
1212 changed files with 100800 additions and 47217 deletions

View File

@ -1,5 +1,4 @@
# [[AUTH]]
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
@ -19,14 +18,10 @@ NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
# This can be used to still allow signups for OIDC connections
# when signup is disabled via `NEXT_PUBLIC_DISABLE_SIGNUP`
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP=""
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
# URL used by the web app to request itself (e.g. local background jobs)
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
@ -113,13 +108,9 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
# [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
# [[BACKGROUND JOBS]]
NEXT_PRIVATE_JOBS_PROVIDER="local"
NEXT_PRIVATE_TRIGGER_API_KEY=
NEXT_PRIVATE_TRIGGER_API_URL=
NEXT_PRIVATE_INNGEST_EVENT_KEY=
# [[FEATURES]]
@ -135,10 +126,5 @@ E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# This is only required for the marketing site
# [[REDIS]]
NEXT_PRIVATE_REDIS_URL=
NEXT_PRIVATE_REDIS_TOKEN=
# [[LOGGER]]
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=

View File

@ -5,6 +5,7 @@ module.exports = {
rules: {
'@next/next/no-img-element': 'off',
'no-unreachable': 'error',
'react-hooks/exhaustive-deps': 'off',
},
settings: {
next: {

View File

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

View File

@ -2,7 +2,7 @@ name: 'Setup node and cache node_modules'
inputs:
node_version:
required: false
default: v20.x
default: v22.x
runs:
using: 'composite'

View File

@ -26,7 +26,8 @@ jobs:
- name: Copy env
run: cp .env.example .env
- uses: ./.github/actions/cache-build
- name: Build app
run: npm run build
build_docker:
name: Build Docker Image

View File

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

View File

@ -10,7 +10,7 @@ on:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
@ -30,7 +30,8 @@ jobs:
- uses: ./.github/actions/node-install
- uses: ./.github/actions/cache-build
- name: Build app
run: npm run build
- name: Initialize CodeQL
uses: github/codeql-action/init@v3

View File

@ -1,7 +1,7 @@
name: Playwright Tests
on:
push:
branches: ['main']
branches: ['main', 'feat/rr7']
pull_request:
branches: ['main']
jobs:
@ -28,7 +28,11 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
- uses: ./.github/actions/cache-build
- name: Build app
run: npm run build
- name: Install playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run ci

View File

@ -4,9 +4,7 @@ tasks:
npm run dx:up &&
cp .env.example .env &&
set -a; source .env &&
export NEXTAUTH_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
command: npm run d
ports:

View File

@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run commitlint -- $1

View File

@ -1,15 +1,9 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
echo "Copying pdf.js"
npm run copy:pdfjs --workspace apps/**
echo "Copying .well-known/ contents"
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
git add "$MONOREPO_ROOT/apps/web/public/"
git add "$MONOREPO_ROOT/apps/remix/public/"
npx lint-staged

2
.npmrc
View File

@ -1 +1,3 @@
auto-install-peers = true
legacy-peer-deps = true
prefer-dedupe = true

View File

@ -1,7 +1,3 @@
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>!
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso&#0045;platform&#0045;plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso&#0032;Platform&#0032;Plan - Whitelabeled&#0032;signing&#0032;flows&#0032;in&#0032;your&#0032;product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px">
@ -73,9 +69,9 @@ Contact us if you are interested in our Enterprise plan for large organizations
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
## Tech Stack
<p align="left">
<a href="https://www.typescriptlang.org"><img src="https://shields.io/badge/TypeScript-3178C6?logo=TypeScript&logoColor=FFF&style=flat-square" alt="TypeScript"></a>
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/next.js-000000?style=flat-square&logo=nextdotjs&logoColor=white" alt="NextJS"></a>
<a href="https://prisma.io"><img width="122" height="20" src="http://made-with.prisma.io/indigo.svg" alt="Made with Prisma" /></a>
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/tailwindcss-0F172A?&logo=tailwindcss" alt="Tailwind CSS"></a>
<a href=""><img src="" alt=""></a>
@ -85,20 +81,17 @@ Contact us if you are interested in our Enterprise plan for large organizations
<a href=""><img src="" alt=""></a>
</p>
- [Typescript](https://www.typescriptlang.org/) - Language
- [Next.js](https://nextjs.org/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [ReactRouter](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [NextAuth.js](https://next-auth.js.org/) - Authentication
- [react-email](https://react.email/) - Email Templates
- [tRPC](https://trpc.io/) - API
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
- [Stripe](https://stripe.com/) - Payments
- [Vercel](https://vercel.com) - Hosting
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
@ -108,7 +101,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js (v18 or above)
- Node.js (v22 or above)
- Postgres SQL Database
- Docker (optional)
@ -171,10 +164,8 @@ git clone https://github.com/<your-username>/documenso
4. Set the following environment variables:
- NEXTAUTH_URL
- NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL
- NEXT_PUBLIC_MARKETING_URL
- NEXT_PRIVATE_DATABASE_URL
- NEXT_PRIVATE_DIRECT_DATABASE_URL
- NEXT_PRIVATE_SMTP_FROM_NAME
@ -243,16 +234,14 @@ cp .env.example .env
The following environment variables must be set:
- `NEXTAUTH_URL`
- `NEXTAUTH_SECRET`
- `NEXT_PUBLIC_WEBAPP_URL`
- `NEXT_PUBLIC_MARKETING_URL`
- `NEXT_PRIVATE_DATABASE_URL`
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
- `NEXT_PRIVATE_SMTP_FROM_NAME`
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for both `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
Now you can install the dependencies and build it:

View File

@ -1,7 +1,7 @@
import Link from 'next/link';
import { Button } from '../primitives/button';
import { Card, CardContent } from '../primitives/card';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
type CallToActionProps = {
className?: string;

View File

@ -1,3 +1,5 @@
import nextra from 'nextra';
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: [
@ -9,9 +11,10 @@ const nextConfig = {
],
};
const withNextra = require('nextra')({
const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.tsx',
codeHighlight: true,
});
module.exports = withNextra(nextConfig);
export default withNextra(nextConfig);

View File

@ -7,8 +7,7 @@
"build": "next build",
"start": "next start -p 3002",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
"clean": "rimraf .next && rimraf node_modules"
},
"dependencies": {
"@documenso/assets": "*",
@ -16,7 +15,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "14.2.6",
"next": "14.2.28",
"next-plausible": "^3.12.0",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",

View File

@ -6,5 +6,6 @@
"solid": "Solid Integration",
"preact": "Preact Integration",
"angular": "Angular Integration",
"css-variables": "CSS Variables"
"css-variables": "CSS Variables",
"authoring": "Authoring"
}

View File

@ -0,0 +1,167 @@
---
title: Authoring
description: Learn how to use embedded authoring to create documents and templates in your application
---
# Embedded Authoring
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
## How Embedded Authoring Works
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
## Creating Documents with Embedded Authoring
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
```jsx
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
const DocumentCreator = () => {
// You'll need to obtain a presign token using your API key
const presignToken = 'YOUR_PRESIGN_TOKEN';
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateDocument
presignToken={presignToken}
externalId="order-12345"
onDocumentCreated={(data) => {
console.log('Document created with ID:', data.documentId);
console.log('External reference ID:', data.externalId);
}}
/>
</div>
);
};
```
## Obtaining a Presign Token
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
You can create a presign token by making a request to:
```
POST /api/v2-beta/embedding/create-presign-token
```
This API endpoint requires authentication with your Documenso API key. The token has a default expiration of 1 hour, but you can customize this duration based on your security requirements.
You can find more details on this request at our [API Documentation](https://openapi.documenso.com/reference#tag/embedding)
## Configuration Options
The `EmbedCreateDocument` component accepts several configuration options:
| Option | Type | Description |
| ------------------ | ------- | ------------------------------------------------------------------ |
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
| `externalId` | string | Optional reference ID from your system to link with the document. |
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
| `css` | string | Optional custom CSS to style the embedded component. |
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
| `className` | string | Optional CSS class name for the iframe. |
## Feature Toggles
You can customize the authoring experience by enabling or disabling specific features:
```jsx
<EmbedCreateDocument
presignToken="YOUR_PRESIGN_TOKEN"
features={{
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureCommunication: true,
}}
/>
```
## Handling Document Creation Events
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
```jsx
<EmbedCreateDocument
presignToken="YOUR_PRESIGN_TOKEN"
externalId="order-12345"
onDocumentCreated={(data) => {
// Navigate to a success page
navigate(`/documents/success?id=${data.documentId}`);
// Or update your database with the document ID
updateOrderDocument(data.externalId, data.documentId);
}}
/>
```
## Styling the Embedded Component
You can customize the appearance of the embedded component using standard CSS classes:
```jsx
<EmbedCreateDocument
className="h-screen w-full rounded-lg border-none shadow-md"
presignToken="YOUR_PRESIGN_TOKEN"
css={`
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
`}
cssVars={{
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
}}
/>
```
## Complete Integration Example
Here's a complete example of integrating document creation in a React application:
```tsx
import { useState } from 'react';
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
function DocumentCreator() {
// In a real application, you would fetch this token from your backend
// using your API key at /api/v2-beta/embedding/create-presign-token
const presignToken = 'YOUR_PRESIGN_TOKEN';
const [documentId, setDocumentId] = useState<number | null>(null);
if (documentId) {
return (
<div>
<h2>Document Created Successfully!</h2>
<p>Document ID: {documentId}</p>
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
</div>
);
}
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateDocument
presignToken={presignToken}
externalId="order-12345"
onDocumentCreated={(data) => {
setDocumentId(data.documentId);
}}
/>
</div>
);
}
export default DocumentCreator;
```
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.

View File

@ -111,6 +111,83 @@ The colors will be automatically converted to the appropriate format internally.
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
## CSS Class Targets
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
### Component Classes
| Class Name | Description |
| --------------------------------- | ----------------------------------------------------------------------- |
| `.embed--Root` | Main container for the embedded signing experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
Field components also expose several data attributes that can be used for styling different states:
| Data Attribute | Values | Description |
| ------------------- | ---------------------------------------------- | ------------------------------------ |
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
### Field Styling Example
```css
/* Style all field containers */
.field--FieldRootContainer {
transition: all 200ms ease;
}
/* Style specific field types */
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
background-color: rgba(0, 0, 0, 0.02);
}
/* Style inserted fields */
.field--FieldRootContainer[data-inserted='true'] {
background-color: var(--primary);
opacity: 0.2;
}
/* Style fields being validated */
.field--FieldRootContainer[data-validate='true'] {
border-color: orange;
}
```
### Example Usage
```css
/* Custom styles for the document widget */
.embed--DocumentWidget {
background-color: #ffffff;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
/* Custom styles for the waiting screen */
.embed--WaitingForTurn {
background-color: #f9fafb;
padding: 2rem;
}
/* Responsive adjustments for the document container */
@media (min-width: 768px) {
.embed--DocumentContainer {
gap: 2rem;
}
}
```
## Related
- [React Integration](/developers/embedding/react)

View File

@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
<EmbedDirectTemplate
token={token}
cssVars={{
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
}}
/>
```
@ -169,6 +169,19 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
## Embedded Authoring
In addition to embedding signing experiences, Documenso now supports **embedded authoring**, allowing your users to create documents and templates directly within your application.
With embedded authoring, you can:
- Create new documents with custom fields
- Configure document properties and settings
- Set up recipients and signing workflows
- Customize the authoring experience
For detailed implementation instructions and code examples, see our [Embedded Authoring](/developers/embedding/authoring) guide.
## Related
- [React Integration](/developers/embedding/react)
@ -178,3 +191,4 @@ If you're using **web components**, the integration process is slightly differen
- [Preact Integration](/developers/embedding/preact)
- [Angular Integration](/developers/embedding/angular)
- [CSS Variables](/developers/embedding/css-variables)
- [Embedded Authoring](/developers/embedding/authoring)

View File

@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
};
return (

View File

@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
`}
// CSS Variables
cssVars={{
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
}}
// Dark Mode Control
darkModeDisabled={true}

View File

@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
};
return (

View File

@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
};
</script>

View File

@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
};
</script>

View File

@ -16,18 +16,16 @@ Pick the one that fits your needs the best.
## Tech Stack
- [Typescript](https://www.typescriptlang.org/) - Language
- [Next.js](https://nextjs.org/) - Framework
- [React Router](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [NextAuth.js](https://next-auth.js.org/) - Authentication
- [react-email](https://react.email/) - Email Templates
- [tRPC](https://trpc.io/) - API
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
- [Stripe](https://stripe.com/) - Payments
- [Vercel](https://vercel.com) - Hosting
<div className="mt-16 flex items-center justify-center gap-4">
<a href="https://documen.so/discord">

View File

@ -32,10 +32,8 @@ Run `npm i` in the root directory to install the dependencies required for the p
Set up the following environment variables in the `.env` file:
```bash
NEXTAUTH_URL
NEXTAUTH_SECRET
NEXT_PUBLIC_WEBAPP_URL
NEXT_PUBLIC_MARKETING_URL
NEXT_PRIVATE_DATABASE_URL
NEXT_PRIVATE_DIRECT_DATABASE_URL
NEXT_PRIVATE_SMTP_FROM_NAME

View File

@ -13,35 +13,13 @@ Documenso uses the following stack to handle translations:
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
## Requirements
You **must** insert **`setupI18nSSR()`** when creating any of the following files:
- Server layout.tsx
- Server page.tsx
- Server loading.tsx
Server meaning it does not have `'use client'` in it.
```tsx
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export default function SomePage() {
setupI18nSSR(); // Required if there are translations within the page, or nested in components.
// Rest of code...
}
```
Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui)
## Quick guide
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
### HTML
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/react/macro**.
```html
<h1>
@ -64,8 +42,9 @@ For text that is broken into elements, but represent a whole sentence, you must
### Constants outside of react components
```tsx
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
export const CONSTANT_WITH_MSG = {
@ -98,31 +77,13 @@ Lingui provides a Plural component to make it easy. See full documentation [here
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
#### Server components
Note that the i18n instance is coming from **setupI18nSSR**.
```tsx
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
export const SomeComponent = () => {
const { i18n } = setupI18nSSR();
const { i18n } = useLingui();
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
};
```
#### Client components
Note that the i18n instance is coming from the **import**.
```tsx
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
export const SomeComponent = () => {
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
};
```

View File

@ -3,6 +3,8 @@ title: Public API
description: Learn how to interact with your documents programmatically using the Documenso public API.
---
import { Callout, Steps } from 'nextra/components';
# Public API
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
@ -13,10 +15,35 @@ Documenso provides a public REST API enabling you to interact with your document
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
## Swagger Documentation
## API V1 - Stable
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
## API V2 - Beta
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
Our new API V2 supports the following typed SDKs:
- [TypeScript](https://github.com/documenso/sdk-typescript)
- [Python](https://github.com/documenso/sdk-python)
- [Go](https://github.com/documenso/sdk-go)
<Callout type="info">
For the staging API, please use the following base URL:
`https://stg-app.documenso.dev/api/v2-beta/`
</Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog)
📖 [Documentation](https://documen.so/api-v2-docs)
💬 [Leave Feedback](https://documen.so/sdk-feedback)
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
## Availability
The API is available to individual users and teams.
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.

View File

@ -532,3 +532,93 @@ Replace the `text` value with the corresponding field type:
- For the `SELECT` field it should be `select`. (check this before merge)
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
## Pre-fill Fields On Document Creation
The API allows you to pre-fill fields on document creation. This is useful when you want to create a document from an existing template and pre-fill the fields with specific values.
To pre-fill a field, you need to make a `POST` request to the `/api/v1/templates/{templateId}/generate-document` endpoint with the field information. Here's an example:
```json
{
"title": "my-document.pdf",
"recipients": [
{
"id": 3,
"name": "Example User",
"email": "example@documenso.com",
"signingOrder": 1,
"role": "SIGNER"
}
],
"prefillFields": [
{
"id": 21,
"type": "text",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "my-value"
},
{
"id": 22,
"type": "number",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "123"
},
{
"id": 23,
"type": "checkbox",
"label": "my-label",
"placeholder": "my-placeholder",
"value": ["option-1", "option-2"]
}
]
}
```
Check out the endpoint in the [API V1 documentation](https://app.documenso.com/api/v1/openapi#:~:text=/%7BtemplateId%7D/-,generate,-%2Ddocument).
### API V2
For API V2, you need to make a `POST` request to the `/api/v2-beta/template/use` endpoint with the field(s) information. Here's an example:
```json
{
"templateId": 111,
"recipients": [
{
"id": 3,
"name": "Example User",
"email": "example@documenso.com",
"signingOrder": 1,
"role": "SIGNER"
}
],
"prefillFields": [
{
"id": 21,
"type": "text",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "my-value"
},
{
"id": 22,
"type": "number",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "123"
},
{
"id": 23,
"type": "checkbox",
"label": "my-label",
"placeholder": "my-placeholder",
"value": ["option-1", "option-2"]
}
]
}
```
Check out the endpoint in the [API V2 documentation](https://openapi.documenso.com/reference#tag/template/POST/template/use).

View File

@ -5,7 +5,7 @@ description: Learn how to self-host Documenso on your server or cloud infrastruc
import { Callout, Steps } from 'nextra/components';
import { CallToAction } from '@documenso/ui/components/call-to-action';
import { CallToAction } from '../../../components/call-to-action';
# Self Hosting
@ -35,10 +35,8 @@ cp .env.example .env
Open the `.env` file and fill in the following variables:
```bash
- NEXTAUTH_URL
- NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL
- NEXT_PUBLIC_MARKETING_URL
- NEXT_PRIVATE_DATABASE_URL
- NEXT_PRIVATE_DIRECT_DATABASE_URL
- NEXT_PRIVATE_SMTP_FROM_NAME
@ -46,8 +44,8 @@ Open the `.env` file and fill in the following variables:
```
<Callout type="info">
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for both
the `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for the
`NEXT_PUBLIC_WEBAPP_URL` variable!
</Callout>
### Install the Dependencies
@ -171,7 +169,6 @@ Run the Docker container with the required environment variables:
```bash
docker run -d \
-p 3000:3000 \
-e NEXTAUTH_URL="<your-nextauth-url>"
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
@ -200,7 +197,6 @@ The environment variables listed above are a subset of those available for confi
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
| `NEXTAUTH_URL` | The URL for the NextAuth.js authentication service. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |

View File

@ -3,7 +3,7 @@ title: Getting Started with Self-Hosting
description: A step-by-step guide to setting up and hosting your own Documenso instance.
---
import { CallToAction } from '@documenso/ui/components/call-to-action';
import { CallToAction } from '../../../components/call-to-action';
# Getting Started with Self-Hosting

View File

@ -21,6 +21,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.signed`
- `document.completed`
- `document.rejected`
- `document.cancelled`
## Create a webhook subscription
@ -37,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
@ -528,6 +529,96 @@ Example payload for the `document.rejected` event:
}
```
Example payload for the `document.rejected` event:
```json
{
"event": "DOCUMENT_CANCELLED",
"payload": {
"id": 7,
"externalId": null,
"userId": 3,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "cm6exvn93006hi02ru90a265a",
"createdAt": "2025-01-27T11:02:14.393Z",
"updatedAt": "2025-01-27T11:03:16.387Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "cm6exvn96006ji02rqvzjvwoy",
"subject": "",
"message": "",
"timezone": "Etc/UTC",
"password": null,
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "",
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": {
"documentDeleted": true,
"documentPending": true,
"recipientSigned": true,
"recipientRemoved": true,
"documentCompleted": true,
"ownerDocumentCompleted": true,
"recipientSigningRequest": true
}
},
"recipients": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "mybirihix@mailinator.com",
"name": "Zorita Baird",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
],
"Recipient": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2025-01-27T11:03:27.730Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Availability
Webhooks are available to individual users and teams.

View File

@ -85,12 +85,13 @@ You can also set the recipient's role, which determines their actions and permis
Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature |
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
| Role | Function | Action required | Signature |
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields

View File

@ -1,5 +1,3 @@
'use client';
import React from 'react';
import NextPlausibleProvider from 'next-plausible';

View File

@ -19,6 +19,22 @@ const themeConfig: DocsThemeConfig = {
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<script
dangerouslySetInnerHTML={{
__html: `
!function(){
if (location.hostname === 'localhost') return;
var e="6c236490c9a68c1",
t=function(){Reo.init({ clientID: e })},
n=document.createElement("script");
n.src="https://static.reo.dev/"+e+"/reo.js";
n.defer=true;
n.onload=t;
document.head.appendChild(n);
}();
`,
}}
/>
</>
);
},

View File

@ -0,0 +1,54 @@
import { DateTime } from 'luxon';
export interface TransformedData {
labels: string[];
datasets: Array<{
label: string;
data: number[];
}>;
}
export function addZeroMonth(transformedData: TransformedData): TransformedData {
const result = {
labels: [...transformedData.labels],
datasets: transformedData.datasets.map((dataset) => ({
label: dataset.label,
data: [...dataset.data],
})),
};
if (result.labels.length === 0) {
return result;
}
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
return result;
}
try {
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
if (!firstMonth.isValid) {
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
for (const format of formats) {
firstMonth = DateTime.fromFormat(result.labels[0], format);
if (firstMonth.isValid) break;
}
if (!firstMonth.isValid) {
console.warn(`Could not parse date: "${result.labels[0]}"`);
return transformedData;
}
}
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
result.labels.unshift(zeroMonth);
result.datasets.forEach((dataset) => {
dataset.data.unshift(0);
});
return result;
} catch (error) {
return transformedData;
}
}

View File

@ -1,7 +1,9 @@
import { DocumentStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { addZeroMonth } from '../add-zero-month';
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
@ -35,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetCompletedDocumentsMonthlyResult = Awaited<

View File

@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { addZeroMonth } from '../add-zero-month';
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
.selectFrom('Recipient')
@ -34,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetSignerConversionMonthlyResult = Awaited<

View File

@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { addZeroMonth } from '../add-zero-month';
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
.selectFrom('User')
@ -32,7 +34,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;

View File

@ -1,5 +1,7 @@
import { DateTime } from 'luxon';
import { addZeroMonth } from './add-zero-month';
type MetricKeys = {
stars: number;
forks: number;
@ -37,31 +39,77 @@ export function transformData({
data: DataEntry;
metric: MetricKey;
}): TransformData {
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);
try {
if (!data || Object.keys(data).length === 0) {
return {
labels: [],
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
};
}
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
});
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
try {
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);
const labels = sortedEntries.map(([date]) => {
const [year, month] = date.split('-');
const dateTime = DateTime.fromObject({
year: Number(year),
month: Number(month),
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
return 0;
}
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
} catch (error) {
console.error('Error sorting entries:', error);
return 0;
}
});
return dateTime.toFormat('MMM yyyy');
});
return {
labels,
datasets: [
{
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
data: sortedEntries.map(([_, stats]) => stats[metric]),
},
],
};
const labels = sortedEntries.map(([date]) => {
try {
const [year, month] = date.split('-');
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
console.warn(`Invalid date format: ${date}`);
return date;
}
const dateTime = DateTime.fromObject({
year: Number(year),
month: Number(month),
});
if (!dateTime.isValid) {
console.warn(`Invalid DateTime object for: ${date}`);
return date;
}
return dateTime.toFormat('MMM yyyy');
} catch (error) {
console.error('Error formatting date:', error, date);
return date;
}
});
const transformedData = {
labels,
datasets: [
{
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
data: sortedEntries.map(([_, stats]) => {
const value = stats[metric];
return typeof value === 'number' && !isNaN(value) ? value : 0;
}),
},
],
};
return addZeroMonth(transformedData);
} catch (error) {
return {
labels: [],
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
};
}
}
// To be on the safer side

View File

@ -7,13 +7,12 @@
"build": "next build",
"start": "next start",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
"clean": "rimraf .next && rimraf node_modules"
},
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.5.0",
"next": "14.2.6"
"next": "14.2.28"
},
"devDependencies": {
"@types/node": "^20",

37
apps/remix/.bin/build.sh Executable file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Exit on error.
set -e
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.."
# Store the original directory
ORIGINAL_DIR=$(pwd)
# Set up trap to ensure we return to original directory
trap 'cd "$ORIGINAL_DIR"' EXIT
cd "$WEB_APP_DIR"
start_time=$(date +%s)
echo "[Build]: Extracting and compiling translations"
npm run translate --prefix ../../
echo "[Build]: Building app"
npm run build:app
echo "[Build]: Building server"
npm run build:server
# Copy over the entry point for the server.
cp server/main.js build/server/main.js
# Copy over all web.js translations
cp -r ../../packages/lib/translations build/server/hono/packages/lib/translations
# Time taken
end_time=$(date +%s)
echo "[Build]: Done in $((end_time - start_time)) seconds"

77
apps/remix/.bin/stripe-dev.sh Executable file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Set Error handling
set -eu
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.."
# Store the original directory
ORIGINAL_DIR=$(pwd)
# Set up trap to ensure we return to original directory
trap 'cd "$ORIGINAL_DIR"' EXIT
cd "$WEB_APP_DIR"
# Define env file paths
ENV_LOCAL_FILE="../../.env.local"
# Function to load environment variable from env files
load_env_var() {
local var_name=$1
local var_value=""
if [ -f "$ENV_LOCAL_FILE" ]; then
var_value=$(grep "^$var_name=" "$ENV_LOCAL_FILE" | cut -d '=' -f2)
fi
# Remove quotes if present
var_value=$(echo "$var_value" | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/")
echo "$var_value"
}
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=$(load_env_var "NEXT_PUBLIC_FEATURE_BILLING_ENABLED")
# Check if NEXT_PUBLIC_FEATURE_BILLING_ENABLED is equal to true
if [ "$NEXT_PUBLIC_FEATURE_BILLING_ENABLED" != "true" ]; then
echo "[ERROR]: NEXT_PUBLIC_FEATURE_BILLING_ENABLED must be enabled."
exit 1
fi
# 1. Load NEXT_PRIVATE_STRIPE_API_KEY from env files
NEXT_PRIVATE_STRIPE_API_KEY=$(load_env_var "NEXT_PRIVATE_STRIPE_API_KEY")
# Check if NEXT_PRIVATE_STRIPE_API_KEY exists
if [ -z "$NEXT_PRIVATE_STRIPE_API_KEY" ]; then
echo "[ERROR]: NEXT_PRIVATE_STRIPE_API_KEY not found in environment files."
echo "[ERROR]: Please make sure it's set in $ENV_LOCAL_FILE"
exit 1
fi
# 2. Check if stripe CLI is installed
if ! command -v stripe &> /dev/null; then
echo "[ERROR]: Stripe CLI is not installed or not in PATH."
echo "[ERROR]: Please install the Stripe CLI: https://stripe.com/docs/stripe-cli"
exit 1
fi
# 3. Check if NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET env key exists
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=$(load_env_var "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET")
if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
echo "╔═════════════════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ! WARNING: NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET MISSING ! ║"
echo "║ ║"
echo "║ Copy the webhook signing secret which will appear in the terminal ║"
echo "║ soon into the env file. ║"
echo "║ ║"
echo "║ The webhook secret will start with whsec_... ║"
echo "║ ║"
echo "╚═════════════════════════════════════════════════════════════════════╝"
fi
echo "[INFO]: Starting Stripe webhook listener..."
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook

4
apps/remix/.dockerignore Normal file
View File

@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

9
apps/remix/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/
# Vite
vite.config.*.timestamp*

22
apps/remix/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

25
apps/remix/Dockerfile.bun Normal file
View File

@ -0,0 +1,25 @@
FROM oven/bun:1 AS dependencies-env
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production
FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]

View File

@ -0,0 +1,26 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]

100
apps/remix/README.md Normal file
View File

@ -0,0 +1,100 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
This template includes three Dockerfiles optimized for different package managers:
- `Dockerfile` - for npm
- `Dockerfile.pnpm` - for pnpm
- `Dockerfile.bun` - for bun
To build and run using Docker:
```bash
# For npm
docker build -t my-app .
# For pnpm
docker build -f Dockerfile.pnpm -t my-app .
# For bun
docker build -f Dockerfile.bun -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

24
apps/remix/app/app.css Normal file
View File

@ -0,0 +1,24 @@
@import '@documenso/ui/styles/theme.css';
@font-face {
font-family: 'Inter';
src: url('/public/fonts/inter-regular.ttf') format('ttf');
/* font-weight: 400;
font-style: normal;
font-display: swap; */
}
@font-face {
font-family: 'Caveat';
src: url('/public/fonts/caveat.ttf') format('ttf');
/* font-weight: 400;
font-style: normal;
font-display: swap; */
}
@layer base {
:root {
--font-sans: 'Inter';
--font-signature: 'Caveat';
}
}

View File

@ -1,12 +1,11 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { signOut } from 'next-auth/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@documenso/prisma/client';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -23,12 +22,13 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteAccountDialogProps = {
export type AccountDeleteDialogProps = {
className?: string;
user: User;
};
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) => {
const { user } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
@ -49,7 +49,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
duration: 5000,
});
return await signOut({ callbackUrl: '/' });
return await authClient.signOut();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
@ -118,7 +118,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
</DialogHeader>
{!hasTwoFactorAuthentication && (
<div className="mt-4">
<div>
<Label>
<Trans>
Please type{' '}

View File

@ -1,13 +1,11 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Document } from '@prisma/client';
import { useNavigate } from 'react-router';
import type { Document } 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';
@ -23,15 +21,15 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type SuperDeleteDocumentDialogProps = {
export type AdminDocumentDeleteDialogProps = {
document: Document;
};
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const navigate = useNavigate();
const [reason, setReason] = useState('');
@ -52,7 +50,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
duration: 5000,
});
router.push('/admin/documents');
await navigate('/admin/documents');
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -1,15 +1,13 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } 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';
@ -25,17 +23,15 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteUserDialogProps = {
export type AdminUserDeleteDialogProps = {
className?: string;
user: User;
};
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
@ -47,13 +43,13 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
id: user.id,
});
await navigate('/admin/users');
toast({
title: _(msg`Account deleted`),
description: _(msg`The account has been deleted successfully.`),
duration: 5000,
});
router.push('/admin/users');
} catch (err) {
const error = AppError.parseError(err);

View File

@ -1,13 +1,12 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } 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';
@ -23,12 +22,15 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DisableUserDialogProps = {
export type AdminUserDisableDialogProps = {
className?: string;
userToDisable: User;
};
export const DisableUserDialog = ({ className, userToDisable }: DisableUserDialogProps) => {
export const AdminUserDisableDialog = ({
className,
userToDisable,
}: AdminUserDisableDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -1,13 +1,12 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } 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';
@ -23,12 +22,12 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnableUserDialogProps = {
export type AdminUserEnableDialogProps = {
className?: string;
userToEnable: User;
};
export const EnableUserDialog = ({ className, userToEnable }: EnableUserDialogProps) => {
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();

View File

@ -0,0 +1,206 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
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 { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
export type NextSigner = {
name: string;
email: string;
};
type ConfirmationDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: (nextSigner?: NextSigner) => void;
hasUninsertedFields: boolean;
isSubmitting: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: NextSigner;
};
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export function AssistantConfirmationDialog({
isOpen,
onClose,
onConfirm,
hasUninsertedFields,
isSubmitting,
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const onOpenChange = () => {
if (isSubmitting) {
return;
}
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
onClose();
};
const handleSubmit = () => {
// Validate the form and submit it if dictate signer is enabled.
if (allowDictateNextSigner) {
void form.handleSubmit(onConfirm)();
return;
}
onConfirm();
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<Form {...form}>
<form>
<fieldset disabled={isSubmitting} className="border-none p-0">
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone.
Please ensure that you have completed prefilling all relevant fields before
proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="mt-4 flex flex-col gap-4">
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
</div>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={isSubmitting}
onClick={handleSubmit}
loading={isSubmitting}
>
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,13 +1,12 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -22,26 +21,26 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDocumentDialogProps = {
type DocumentDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export const DeleteDocumentDialog = ({
export const DocumentDeleteDialog = ({
id,
open,
onOpenChange,
onDelete,
status,
documentTitle,
canManageDocument,
}: DeleteDocumentDialogProps) => {
const router = useRouter();
}: DocumentDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
@ -52,8 +51,7 @@ export const DeleteDocumentDialog = ({
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
onSuccess: () => {
router.refresh();
onSuccess: async () => {
void refreshLimits();
toast({
@ -62,8 +60,18 @@ export const DeleteDocumentDialog = ({
duration: 5000,
});
await onDelete?.();
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
@ -73,19 +81,6 @@ export const DeleteDocumentDialog = ({
}
}, [open, status]);
const onDelete = async () => {
try {
await deleteDocument({ documentId: id });
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
}
};
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
@ -151,7 +146,7 @@ export const DeleteDocumentDialog = ({
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
@ -194,7 +189,7 @@ export const DeleteDocumentDialog = ({
<Button
type="button"
loading={isPending}
onClick={onDelete}
onClick={() => void deleteDocument({ documentId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>

View File

@ -1,10 +1,9 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
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 {
@ -14,30 +13,37 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateDocumentDialogProps = {
import { useOptionalCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
team?: Pick<Team, 'id' | 'url'>;
};
export const DuplicateDocumentDialog = ({
export const DocumentDuplicateDialog = ({
id,
open,
onOpenChange,
team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
}: DocumentDuplicateDialogProps) => {
const navigate = useNavigate();
const { toast } = useToast();
const { _ } = useLingui();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
documentId: id,
});
const team = useOptionalCurrentTeam();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
{
documentId: id,
},
{
enabled: open === true,
},
);
const documentData = document?.documentData
? {
@ -50,15 +56,14 @@ export const DuplicateDocumentDialog = ({
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: ({ documentId }) => {
router.push(`${documentsPath}/${documentId}/edit`);
onSuccess: async ({ documentId }) => {
toast({
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
await navigate(`${documentsPath}/${documentId}/edit`);
onOpenChange(false);
},
});
@ -92,7 +97,7 @@ export const DuplicateDocumentDialog = ({
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<LazyPDFViewer key={document?.id} documentData={documentData} />
<PDFViewer key={document?.id} documentData={documentData} />
</div>
)}

View File

@ -1,11 +1,10 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@ -26,30 +25,28 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type MoveDocumentDialogProps = {
type DocumentMoveDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
@ -97,9 +94,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
/>
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
)}
<AvatarFallback className="text-sm text-gray-400">

View File

@ -0,0 +1,216 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentMoveToFolderDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveDocumentFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
export const DocumentMoveToFolderDialog = ({
documentId,
open,
onOpenChange,
currentFolderId,
...props
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
defaultValues: {
folderId: currentFolderId,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,
},
{
enabled: open,
},
);
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
useEffect(() => {
if (!open) {
form.reset();
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await moveDocumentToFolder({
documentId,
folderId: data.folderId ?? null,
});
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const documentsPath = formatDocumentsPath(team?.url);
if (data.folderId) {
void navigate(`${documentsPath}/f/${data.folderId}`);
} else {
void navigate(documentsPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the document to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the document.`),
variant: 'destructive',
});
}
};
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a folder to move this document to.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
</Button>
{folders?.data.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={
isFoldersLoading || form.formState.isSubmitting || currentFolderId === null
}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,19 +1,18 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient, SigningStatus } from '@prisma/client';
import { History } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
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';
import { Button } from '@documenso/ui/primitives/button';
@ -37,16 +36,15 @@ import {
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
import { useOptionalCurrentTeam } from '~/providers/team';
import { StackAvatar } from '../general/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
document: Document & {
team: Pick<Team, 'id' | 'url'> | null;
};
export type DocumentResendDialogProps = {
document: TDocumentRow;
recipients: Recipient[];
team?: Pick<Team, 'id' | 'url'>;
};
export const ZResendDocumentFormSchema = z.object({
@ -57,17 +55,15 @@ export const ZResendDocumentFormSchema = z.object({
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const ResendDocumentActionItem = ({
document,
recipients,
team,
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useSession();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
const isOwner = document.userId === user.id;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =

View File

@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type CreateFolderDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
const form = useForm<TCreateFolderFormSchema>({
resolver: zodResolver(ZCreateFolderFormSchema),
defaultValues: {
name: '',
},
});
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.DOCUMENT,
});
setIsCreateFolderOpen(false);
toast({
description: 'Folder created successfully',
});
const documentsPath = formatDocumentsPath(team?.url);
void navigate(`${documentsPath}/f/${newFolder.id}`);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: 'Failed to create folder',
description: _(msg`This folder name is already taken.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to create folder',
description: _(msg`An unknown error occurred while creating the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isCreateFolderOpen) {
form.reset();
}
}, [isCreateFolderOpen, form]);
return (
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="flex items-center space-x-2">
<FolderPlusIcon className="h-4 w-4" />
<span>Create Folder</span>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogDescription>
Enter a name for your new folder. Folders help you organize your documents.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input placeholder="My Folder" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,159 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
}),
});
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
const form = useForm<TDeleteFolderFormSchema>({
resolver: zodResolver(ZDeleteFolderFormSchema),
defaultValues: {
confirmText: '',
},
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
});
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to delete does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Folder</DialogTitle>
<DialogDescription>
Are you sure you want to delete this folder?
{folder && folder._count.documents > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.documents} document(s). Deleting it will also
delete all documents in the folder.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
delete all subfolders and their contents.
</span>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={deleteMessage} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
Delete
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,169 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderMoveDialogProps = {
foldersData: TFolderWithSubfolders[] | undefined;
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveFolderFormSchema = z.object({
targetFolderId: z.string().nullable(),
});
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
export const FolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: FolderMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
defaultValues: {
targetFolderId: folder?.parentId ?? null,
},
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return;
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId || null,
});
onOpenChange(false);
toast({
title: 'Folder moved successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to move does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-2">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(null)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
Move Folder
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,173 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderSettingsDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderSettingsDialog = ({
folder,
isOpen,
onOpenChange,
}: FolderSettingsDialogProps) => {
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
name: folder?.name ?? '',
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) return;
try {
await updateFolder({
id: folder.id,
name: data.name,
visibility: isTeamContext
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
title: _(msg`Folder updated successfully`),
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Folder not found`),
});
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Folder Settings</DialogTitle>
<DialogDescription>Manage the settings for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isTeamContext && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
Managers and above
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,10 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
@ -38,7 +37,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreatePasskeyDialogProps = {
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -51,7 +50,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
const parser = new UAParser();
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);

View File

@ -1,18 +1,17 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import type { Template, TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import {
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,

View File

@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader, TagIcon } from 'lucide-react';
@ -20,18 +21,18 @@ import {
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreateTeamCheckoutDialogProps = {
export type TeamCheckoutCreateDialogProps = {
pendingTeamId: number | null;
onClose: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const MotionCard = motion(Card);
export const CreateTeamCheckoutDialog = ({
export const TeamCheckoutCreateDialog = ({
pendingTeamId,
onClose,
...props
}: CreateTeamCheckoutDialogProps) => {
}: TeamCheckoutCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -1,18 +1,17 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
@ -37,7 +36,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreateTeamDialogProps = {
export type TeamCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -48,12 +47,12 @@ const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => {
export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [open, setOpen] = useState(false);
@ -80,7 +79,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
setOpen(false);
if (response.paymentRequired) {
router.push(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
return;
}
@ -120,7 +119,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open, setOpen, updateSearchParams]);
}, [actionSearchParam, open]);
useEffect(() => {
form.reset();
@ -201,7 +200,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${WEBAPP_BASE_URL}/t/${field.value}`
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
<Trans>A unique URL to identify your team</Trans>
)}

View File

@ -1,13 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
@ -34,14 +32,14 @@ import { Input } from '@documenso/ui/primitives/input';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTeamDialogProps = {
export type TeamDeleteDialogProps = {
teamId: number;
teamName: string;
trigger?: React.ReactNode;
};
export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialogProps) => {
const router = useRouter();
export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { _ } = useLingui();
@ -74,9 +72,9 @@ export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialog
duration: 5000,
});
setOpen(false);
await navigate('/settings/teams');
router.push('/settings/teams');
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);

View File

@ -1,15 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@ -36,7 +34,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AddTeamEmailDialogProps = {
export type TeamEmailAddDialogProps = {
teamId: number;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -48,13 +46,12 @@ const ZCreateTeamEmailFormSchema = ZCreateTeamEmailVerificationMutationSchema.pi
type TCreateTeamEmailFormSchema = z.infer<typeof ZCreateTeamEmailFormSchema>;
export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDialogProps) => {
const router = useRouter();
export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const form = useForm<TCreateTeamEmailFormSchema>({
resolver: zodResolver(ZCreateTeamEmailFormSchema),
@ -81,7 +78,7 @@ export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDi
duration: 5000,
});
router.refresh();
await revalidate();
setOpen(false);
} catch (err) {

View File

@ -1,15 +1,13 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Prisma } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -25,7 +23,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type RemoveTeamEmailDialogProps = {
export type TeamEmailDeleteDialogProps = {
trigger?: React.ReactNode;
teamName: string;
team: Prisma.TeamGetPayload<{
@ -42,13 +40,12 @@ export type RemoveTeamEmailDialogProps = {
}>;
};
export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEmailDialogProps) => {
export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const { revalidate } = useRevalidator();
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
@ -97,7 +94,7 @@ export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEma
await deleteTeamEmailVerification({ teamId: team.id });
}
router.refresh();
await revalidate();
};
return (
@ -127,7 +124,7 @@ export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEma
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials(
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
)}

View File

@ -1,17 +1,15 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamEmail } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import type { TeamEmail } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -34,7 +32,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type UpdateTeamEmailDialogProps = {
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -45,17 +43,16 @@ const ZUpdateTeamEmailFormSchema = z.object({
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
export const UpdateTeamEmailDialog = ({
export const TeamEmailUpdateDialog = ({
teamEmail,
trigger,
...props
}: UpdateTeamEmailDialogProps) => {
const router = useRouter();
}: TeamEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const form = useForm<TUpdateTeamEmailFormSchema>({
resolver: zodResolver(ZUpdateTeamEmailFormSchema),
@ -81,7 +78,7 @@ export const UpdateTeamEmailDialog = ({
duration: 5000,
});
router.refresh();
await revalidate();
setOpen(false);
} catch (err) {

View File

@ -1,13 +1,12 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import type { TeamMemberRole } from '@documenso/prisma/client';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -23,7 +22,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LeaveTeamDialogProps = {
export type TeamLeaveDialogProps = {
teamId: number;
teamName: string;
teamAvatarImageId?: string | null;
@ -31,13 +30,13 @@ export type LeaveTeamDialogProps = {
trigger?: React.ReactNode;
};
export const LeaveTeamDialog = ({
export const TeamLeaveDialog = ({
trigger,
teamId,
teamName,
teamAvatarImageId,
role,
}: LeaveTeamDialogProps) => {
}: TeamLeaveDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
@ -89,7 +88,7 @@ export const LeaveTeamDialog = ({
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${teamAvatarImageId}`}
avatarSrc={formatAvatarUrl(teamAvatarImageId)}
avatarFallback={teamName.slice(0, 1).toUpperCase()}
primaryText={teamName}
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}

View File

@ -1,9 +1,8 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
@ -20,7 +19,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTeamMemberDialogProps = {
export type TeamMemberDeleteDialogProps = {
teamId: number;
teamName: string;
teamMemberId: number;
@ -29,14 +28,14 @@ export type DeleteTeamMemberDialogProps = {
trigger?: React.ReactNode;
};
export const DeleteTeamMemberDialog = ({
export const TeamMemberDeleteDialog = ({
trigger,
teamId,
teamName,
teamMemberId,
teamMemberName,
teamMemberEmail,
}: DeleteTeamMemberDialogProps) => {
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();

View File

@ -1,10 +1,10 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
@ -13,7 +13,6 @@ import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { cn } from '@documenso/ui/lib/utils';
@ -47,9 +46,9 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type InviteTeamMembersDialogProps = {
currentUserTeamRole: TeamMemberRole;
teamId: number;
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberInviteDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -96,12 +95,7 @@ const ZImportTeamMemberSchema = z.array(
}),
);
export const InviteTeamMembersDialog = ({
currentUserTeamRole,
teamId,
trigger,
...props
}: InviteTeamMembersDialogProps) => {
export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
@ -109,6 +103,8 @@ export const InviteTeamMembersDialog = ({
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<TInviteTeamMembersFormSchema>({
resolver: zodResolver(ZInviteTeamMembersFormSchema),
defaultValues: {
@ -142,7 +138,7 @@ export const InviteTeamMembersDialog = ({
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
try {
await createTeamMemberInvites({
teamId,
teamId: team.id,
invitations,
});
@ -204,7 +200,7 @@ export const InviteTeamMembersDialog = ({
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err.message);
console.error(err);
toast({
title: _(msg`Something went wrong`),
@ -325,11 +321,13 @@ export const InviteTeamMembersDialog = ({
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map(
(role) => (
<SelectItem key={role} value={role}>
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>

View File

@ -1,17 +1,16 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -40,7 +39,7 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type UpdateTeamMemberDialogProps = {
export type TeamMemberUpdateDialogProps = {
currentUserTeamRole: TeamMemberRole;
trigger?: React.ReactNode;
teamId: number;
@ -55,7 +54,7 @@ const ZUpdateTeamMemberFormSchema = z.object({
type ZUpdateTeamMemberSchema = z.infer<typeof ZUpdateTeamMemberFormSchema>;
export const UpdateTeamMemberDialog = ({
export const TeamMemberUpdateDialog = ({
currentUserTeamRole,
trigger,
teamId,
@ -63,7 +62,7 @@ export const UpdateTeamMemberDialog = ({
teamMemberName,
teamMemberRole,
...props
}: UpdateTeamMemberDialogProps) => {
}: TeamMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();

View File

@ -1,14 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@ -42,24 +40,24 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TransferTeamDialogProps = {
export type TeamTransferDialogProps = {
teamId: number;
teamName: string;
ownerUserId: number;
trigger?: React.ReactNode;
};
export const TransferTeamDialog = ({
export const TeamTransferDialog = ({
trigger,
teamId,
teamName,
ownerUserId,
}: TransferTeamDialogProps) => {
const router = useRouter();
}: TeamTransferDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutateAsync: requestTeamOwnershipTransfer } =
trpc.team.requestTeamOwnershipTransfer.useMutation();
@ -67,7 +65,7 @@ export const TransferTeamDialog = ({
const {
data,
refetch: refetchTeamMembers,
isLoading: loadingTeamMembers,
isPending: loadingTeamMembers,
isLoadingError: loadingTeamMembersError,
} = trpc.team.getTeamMembers.useQuery({
teamId,
@ -102,7 +100,7 @@ export const TransferTeamDialog = ({
clearPaymentMethods,
});
router.refresh();
await revalidate();
toast({
title: _(msg`Success`),

View File

@ -0,0 +1,274 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZBulkSendFormSchema = z.object({
file: z.instanceof(File),
sendImmediately: z.boolean().default(false),
});
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
trigger?: React.ReactNode;
onSuccess?: () => void;
};
export const TemplateBulkSendDialog = ({
templateId,
recipients,
trigger,
onSuccess,
}: TemplateBulkSendDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
sendImmediately: false,
},
});
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [
`recipient_${index + 1}_email`,
`recipient_${index + 1}_name`,
]);
const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
const csv = [headers.join(','), exampleRow.join(',')].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {
href: url,
download: 'template.csv',
});
a.click();
window.URL.revokeObjectURL(url);
};
const onSubmit = async (values: TBulkSendFormSchema) => {
try {
const csv = await values.file.text();
await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
toast({
title: _(msg`Success`),
description: _(
msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
),
});
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'Failed to upload CSV. Please check the file format and try again.',
variant: 'destructive',
});
}
};
return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Bulk Send Template via CSV</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Upload a CSV file to create multiple documents from this template. Each row represents
one document with its recipient details.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<div className="bg-muted/70 rounded-lg border p-4">
<h3 className="text-sm font-medium">
<Trans>CSV Structure</Trans>
</h3>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>
For each recipient, provide their email (required) and name (optional) in separate
columns. Download the template CSV below for the correct format.
</Trans>
</p>
<p className="mt-4 text-sm">
<Trans>Current recipients:</Trans>
</p>
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
{recipients.map((recipient, index) => (
<li key={index}>
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-y-2">
<Button onClick={onDownloadTemplate} variant="outline" type="button">
<Trans>Download Template CSV</Trans>
</Button>
<p className="text-muted-foreground text-xs">
<Trans>Pre-formatted CSV template with example data.</Trans>
</p>
</div>
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormItem>
<FormControl>
{!value ? (
<Button asChild variant="outline" className="w-full">
<label className="cursor-pointer">
<input
type="file"
accept=".csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
disabled={form.formState.isSubmitting}
/>
<Upload className="mr-2 h-4 w-4" />
<Trans>Upload CSV</Trans>
</label>
</Button>
) : (
<div className="flex h-10 items-center rounded-md border px-3">
<div className="flex flex-1 items-center gap-2">
<FileIcon className="text-muted-foreground h-4 w-4" />
<span className="flex-1 truncate text-sm">{value.name}</span>
</div>
<Button
type="button"
variant="link"
className="text-destructive hover:text-destructive p-0 text-xs"
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
<span className="sr-only">
<Trans>Remove</Trans>
</span>
</Button>
</div>
)}
</FormControl>
{error && <p className="text-destructive text-sm">{error.message}</p>}
<p className="text-muted-foreground text-xs">
<Trans>
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
template defaults.
</Trans>
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sendImmediately"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id="send-immediately"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
htmlFor="send-immediately"
className="text-muted-foreground ml-2 flex items-center text-sm"
>
<Trans>Send documents to recipients immediately</Trans>
</label>
</div>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Upload and Process</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,15 +1,12 @@
'use client';
import { useState } from 'react';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { FilePlus, Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useNavigate } from 'react-router';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -26,21 +23,21 @@ import {
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
type NewTemplateDialogProps = {
teamId?: number;
type TemplateCreateDialogProps = {
templateRootPath: string;
folderId?: string;
};
export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) => {
const router = useRouter();
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { data: session } = useSession();
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const onFileDrop = async (file: File) => {
@ -51,15 +48,12 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
setIsUploadingFile(true);
try {
const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,
data,
});
const response = await putPdfFile(file);
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId,
templateDocumentDataId: response.id,
folderId: folderId,
});
toast({
@ -70,9 +64,9 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
duration: 5000,
});
setShowNewTemplateDialog(false);
setShowTemplateCreateDialog(false);
router.push(`${templateRootPath}/${id}/edit`);
await navigate(`${templateRootPath}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
@ -86,11 +80,11 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
return (
<Dialog
open={showNewTemplateDialog}
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
open={showTemplateCreateDialog}
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>New Template</Trans>
</Button>

View File

@ -1,7 +1,6 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -15,22 +14,25 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteTemplateDialogProps = {
type TemplateDeleteDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onDelete?: () => Promise<void> | void;
};
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
const router = useRouter();
export const TemplateDeleteDialog = ({
id,
open,
onOpenChange,
onDelete,
}: TemplateDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
router.refresh();
onSuccess: async () => {
await onDelete?.();
toast({
title: _(msg`Template deleted`),

View File

@ -1,14 +1,12 @@
'use client';
import { useState } from 'react';
import React, { useState } from 'react';
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
import { LinkIcon } from 'lucide-react';
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
export type TemplateDirectLinkDialogWrapperProps = {
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };

View File

@ -1,11 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
type Recipient,
RecipientRole,
type Template,
type TemplateDirectLink,
} from '@prisma/client';
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
import { Link, useRevalidator } from 'react-router';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -14,12 +19,6 @@ import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import {
type Recipient,
RecipientRole,
type Template,
type TemplateDirectLink,
} from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -65,9 +64,9 @@ export const TemplateDirectLinkDialog = ({
const { toast } = useToast();
const { quota, remaining } = useLimits();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const [, copy] = useCopyToClipboard();
const router = useRouter();
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
const [token, setToken] = useState(template.directLink?.token ?? null);
@ -77,7 +76,11 @@ export const TemplateDirectLinkDialog = ({
);
const validDirectTemplateRecipients = useMemo(
() => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC),
() =>
template.recipients.filter(
(recipient) =>
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
),
[template.recipients],
);
@ -86,12 +89,12 @@ export const TemplateDirectLinkDialog = ({
isPending: isCreatingTemplateDirectLink,
reset: resetCreateTemplateDirectLink,
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: (data) => {
onSuccess: async (data) => {
await revalidate();
setToken(data.token);
setIsEnabled(data.enabled);
setCurrentStep('MANAGE');
router.refresh();
},
onError: () => {
setSelectedRecipientId(null);
@ -106,7 +109,9 @@ export const TemplateDirectLinkDialog = ({
const { mutateAsync: toggleTemplateDirectLink, isPending: isTogglingTemplateAccess } =
trpcReact.template.toggleTemplateDirectLink.useMutation({
onSuccess: (data) => {
onSuccess: async (data) => {
await revalidate();
const enabledDescription = msg`Direct link signing has been enabled`;
const disabledDescription = msg`Direct link signing has been disabled`;
@ -129,7 +134,9 @@ export const TemplateDirectLinkDialog = ({
const { mutateAsync: deleteTemplateDirectLink, isPending: isDeletingTemplateDirectLink } =
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: () => {
onSuccess: async () => {
await revalidate();
onOpenChange(false);
setToken(null);
@ -139,7 +146,6 @@ export const TemplateDirectLinkDialog = ({
duration: 5000,
});
router.refresh();
setToken(null);
},
onError: () => {
@ -231,7 +237,7 @@ export const TemplateDirectLinkDialog = ({
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
href="/settings/billing"
to="/settings/billing"
>
Upgrade your account to continue!
</Link>
@ -432,7 +438,7 @@ export const TemplateDirectLinkDialog = ({
await toggleTemplateDirectLink({
templateId: template.id,
enabled: isEnabled,
}).catch((e) => null);
}).catch(() => null);
onOpenChange(false);
}}

View File

@ -1,7 +1,6 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -15,28 +14,23 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
type TemplateDuplicateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
export const TemplateDuplicateDialog = ({
id,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
const router = useRouter();
}: TemplateDuplicateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isPending } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: _(msg`Template duplicated`),
description: _(msg`Your template has been duplicated successfully.`),

View File

@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type TemplateFolderCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TemplateFolderCreateDialog = ({
trigger,
...props
}: TemplateFolderCreateDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const { folderId } = useParams();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
const form = useForm<TCreateFolderFormSchema>({
resolver: zodResolver(ZCreateFolderFormSchema),
defaultValues: {
name: '',
},
});
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.TEMPLATE,
});
setIsCreateFolderOpen(false);
toast({
description: _(msg`Folder created successfully`),
});
const templatesPath = formatTemplatesPath(team?.url);
void navigate(`${templatesPath}/f/${newFolder.id}`);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: _(msg`Failed to create folder`),
description: _(msg`This folder name is already taken.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Failed to create folder`),
description: _(msg`An unknown error occurred while creating the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isCreateFolderOpen) {
form.reset();
}
}, [isCreateFolderOpen, form]);
return (
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="flex items-center space-x-2">
<FolderPlusIcon className="h-4 w-4" />
<span>Create Folder</span>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogDescription>
Enter a name for your new folder. Folders help you organize your templates.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input placeholder="My Folder" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,163 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TemplateFolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TemplateFolderDeleteDialog = ({
folder,
isOpen,
onOpenChange,
}: TemplateFolderDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
}),
});
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
const form = useForm<TDeleteFolderFormSchema>({
resolver: zodResolver(ZDeleteFolderFormSchema),
defaultValues: {
confirmText: '',
},
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
});
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to delete does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Folder</DialogTitle>
<DialogDescription>
Are you sure you want to delete this folder?
{folder && folder._count.documents > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.documents} document(s). Deleting it will also
delete all documents in the folder.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
delete all subfolders and their contents.
</span>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={deleteMessage} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
Delete
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,175 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TemplateFolderMoveDialogProps = {
foldersData: TFolderWithSubfolders[] | undefined;
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveFolderFormSchema = z.object({
targetFolderId: z.string().optional(),
});
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
export const TemplateFolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: TemplateFolderMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
defaultValues: {
targetFolderId: folder?.parentId ?? '',
},
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return;
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId ?? '',
});
onOpenChange(false);
toast({
title: 'Folder moved successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to move does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-2">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(undefined)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={
form.formState.isSubmitting ||
form.getValues('targetFolderId') === folder?.parentId
}
>
Move Folder
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,176 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { FolderType } from '@documenso/lib/types/folder-type';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateFolderSettingsDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const TemplateFolderSettingsDialog = ({
folder,
isOpen,
onOpenChange,
}: TemplateFolderSettingsDialogProps) => {
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const isTemplateFolder = folder?.type === FolderType.TEMPLATE;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
name: folder?.name ?? '',
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) return;
try {
await updateFolder({
id: folder.id,
name: data.name,
visibility:
isTeamContext && !isTemplateFolder
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
title: _(msg`Folder updated successfully`),
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Folder not found`),
});
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Folder Settings</DialogTitle>
<DialogDescription>Manage the settings for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isTeamContext && !isTemplateFolder && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
Managers and above
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,13 +1,12 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@ -28,29 +27,46 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type MoveTemplateDialogProps = {
type TemplateMoveDialogProps = {
templateId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onMove?: ({
templateId,
teamUrl,
}: {
templateId: number;
teamUrl: string;
}) => Promise<void> | void;
};
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
const router = useRouter();
export const TemplateMoveDialog = ({
templateId,
open,
onOpenChange,
onMove,
}: TemplateMoveDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: () => {
router.refresh();
onSuccess: async () => {
const team = teams?.find((team) => team.id === selectedTeamId);
if (team) {
await onMove?.({ templateId, teamUrl: team.url });
}
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (err) => {
@ -73,7 +89,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
},
});
const onMove = async () => {
const handleOnMove = async () => {
if (!selectedTeamId) {
return;
}
@ -108,9 +124,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
/>
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
)}
<AvatarFallback className="text-sm text-gray-400">
@ -130,7 +144,11 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
<Button
onClick={handleOnMove}
loading={isPending}
disabled={!selectedTeamId || isPending}
>
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>

View File

@ -0,0 +1,213 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await moveTemplateToFolder({
templateId,
folderId: data.folderId ?? null,
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team?.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
</Button>
{folders?.data?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,14 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import * as z from 'zod';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
@ -18,8 +18,6 @@ import {
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Recipient } from '@documenso/prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -94,7 +92,7 @@ const ZAddRecipientsForNewDocumentSchema = z
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type UseTemplateDialogProps = {
export type TemplateUseDialogProps = {
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
@ -103,19 +101,19 @@ export type UseTemplateDialogProps = {
trigger?: React.ReactNode;
};
export function UseTemplateDialog({
export function TemplateUseDialog({
recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
templateId,
templateSigningOrder,
trigger,
}: UseTemplateDialogProps) {
const router = useRouter();
}: TemplateUseDialogProps) {
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const form = useForm<TAddRecipientsForNewDocumentSchema>({
@ -179,7 +177,7 @@ export function UseTemplateDialog({
documentPath += '?action=view-signing-links';
}
router.push(documentPath);
await navigate(documentPath);
} catch (err) {
const error = AppError.parseError(err);

View File

@ -1,16 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { ApiToken } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -33,35 +30,31 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTokenDialogProps = {
teamId?: number;
import { useOptionalCurrentTeam } from '~/providers/team';
export type TokenDeleteDialogProps = {
token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void;
children?: React.ReactNode;
};
export default function DeleteTokenDialog({
teamId,
token,
onDelete,
children,
}: DeleteTokenDialogProps) {
export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const team = useOptionalCurrentTeam();
const [isOpen, setIsOpen] = useState(false);
const deleteMessage = _(msg`delete ${token.name}`);
const ZDeleteTokenDialogSchema = z.object({
const ZTokenDeleteDialogSchema = z.object({
tokenName: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
}),
});
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
onSuccess() {
@ -70,7 +63,7 @@ export default function DeleteTokenDialog({
});
const form = useForm<TDeleteTokenByIdMutationSchema>({
resolver: zodResolver(ZDeleteTokenDialogSchema),
resolver: zodResolver(ZTokenDeleteDialogSchema),
values: {
tokenName: '',
},
@ -80,7 +73,7 @@ export default function DeleteTokenDialog({
try {
await deleteTokenMutation({
id: token.id,
teamId,
teamId: team?.id,
});
toast({
@ -90,8 +83,6 @@ export default function DeleteTokenDialog({
});
setIsOpen(false);
router.refresh();
} catch (error) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -1,12 +1,9 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -39,22 +36,20 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
export type CreateWebhookDialogProps = {
export type WebhookCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const team = useOptionalCurrentTeam();
const [open, setOpen] = useState(false);
@ -94,8 +89,6 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
});
form.reset();
router.refresh();
} catch (err) {
toast({
title: _(msg`Error`),
@ -191,7 +184,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
@ -237,14 +230,13 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</div>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>

View File

@ -1,16 +1,13 @@
'use effect';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { Webhook } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -35,18 +32,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DeleteWebhookDialogProps = {
export type WebhookDeleteDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
onDelete?: () => void;
children: React.ReactNode;
};
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const team = useOptionalCurrentTeam();
const [open, setOpen] = useState(false);
@ -81,8 +76,6 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr
});
setOpen(false);
router.refresh();
} catch (error) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -0,0 +1,355 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
interface ConfigureDocumentAdvancedSettingsProps {
control: Control<TConfigureEmbedFormSchema>;
isSubmitting: boolean;
}
export const ConfigureDocumentAdvancedSettings = ({
control,
isSubmitting,
}: ConfigureDocumentAdvancedSettingsProps) => {
const { _ } = useLingui();
const form = useFormContext<TConfigureEmbedFormSchema>();
const { features } = useConfigureDocument();
const { watch, setValue } = form;
// Lift watch() calls to reduce re-renders
const distributionMethod = watch('meta.distributionMethod');
const emailSettings = watch('meta.emailSettings');
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<Trans>Advanced Settings</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Configure additional options and preferences</Trans>
</p>
<Tabs defaultValue="general">
<TabsList className="mb-6 inline-flex">
<TabsTrigger value="general" className="px-4">
<Trans>General</Trans>
</TabsTrigger>
{features.allowConfigureCommunication && (
<TabsTrigger value="communication" className="px-4">
<Trans>Communication</Trans>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="general" className="mt-0">
<div className="flex flex-col space-y-6">
{/* <FormField
control={control}
name="meta.externalId"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>External ID</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add an external ID to the document. This can be used to identify the
document in external systems.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
{features.allowConfigureSignatureTypes && (
<FormField
control={control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Allowed Signature Types</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureLanguage && (
<FormField
control={control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Language</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureDateFormat && (
<FormField
control={control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureTimezone && (
<FormField
control={control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureRedirectUrl && (
<FormField
control={control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Redirect URL</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</TabsContent>
{features.allowConfigureCommunication && (
<TabsContent value="communication" className="mt-0">
<div className="flex flex-col space-y-6">
<FormField
control={control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Distribution Method</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DocumentDistributionMethod.EMAIL}>
<Trans>Email</Trans>
</SelectItem>
<SelectItem value={DocumentDistributionMethod.NONE}>
<Trans>None</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Choose how to distribute your document to recipients. Email will send
notifications, None will generate signing links for manual distribution.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<fieldset
className="flex flex-col space-y-6 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!isEmailDistribution}
>
<FormField
control={control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="subject">
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="message">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentSendEmailMessageHelper />
<DocumentEmailCheckboxes
className={`mt-2 ${!isEmailDistribution ? 'pointer-events-none' : ''}`}
value={emailSettings}
onChange={(value) => setValue('meta.emailSettings', value)}
/>
</fieldset>
</div>
</TabsContent>
)}
</Tabs>
</div>
);
};

View File

@ -0,0 +1,68 @@
import { createContext, useContext } from 'react';
export type ConfigureDocumentContext = {
// General
isTemplate: boolean;
isPersisted: boolean;
// Features
features: {
allowConfigureSignatureTypes?: boolean;
allowConfigureLanguage?: boolean;
allowConfigureDateFormat?: boolean;
allowConfigureTimezone?: boolean;
allowConfigureRedirectUrl?: boolean;
allowConfigureCommunication?: boolean;
};
};
const ConfigureDocumentContext = createContext<ConfigureDocumentContext | null>(null);
export type ConfigureDocumentProviderProps = {
isTemplate?: boolean;
isPersisted?: boolean;
features: {
allowConfigureSignatureTypes?: boolean;
allowConfigureLanguage?: boolean;
allowConfigureDateFormat?: boolean;
allowConfigureTimezone?: boolean;
allowConfigureRedirectUrl?: boolean;
allowConfigureCommunication?: boolean;
};
children: React.ReactNode;
};
export const ConfigureDocumentProvider = ({
isTemplate,
isPersisted,
features,
children,
}: ConfigureDocumentProviderProps) => {
return (
<ConfigureDocumentContext.Provider
value={{
isTemplate: isTemplate ?? false,
isPersisted: isPersisted ?? false,
features: {
allowConfigureSignatureTypes: features.allowConfigureSignatureTypes ?? true,
allowConfigureLanguage: features.allowConfigureLanguage ?? true,
allowConfigureDateFormat: features.allowConfigureDateFormat ?? true,
allowConfigureTimezone: features.allowConfigureTimezone ?? true,
allowConfigureRedirectUrl: features.allowConfigureRedirectUrl ?? true,
allowConfigureCommunication: features.allowConfigureCommunication ?? true,
},
}}
>
{children}
</ConfigureDocumentContext.Provider>
);
};
export const useConfigureDocument = () => {
const context = useContext(ConfigureDocumentContext);
if (!context) {
throw new Error('useConfigureDocument must be used within a ConfigureDocumentProvider');
}
return context;
};

View File

@ -0,0 +1,413 @@
import { useCallback, useRef } from 'react';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVertical, HelpCircle, Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
// Define a type for signer items
type SignerItem = TConfigureEmbedFormSchema['signers'][number];
export interface ConfigureDocumentRecipientsProps {
control: Control<TConfigureEmbedFormSchema>;
isSubmitting: boolean;
}
export const ConfigureDocumentRecipients = ({
control,
isSubmitting,
}: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const $sensorApi = useRef<SensorAPI | null>(null);
const {
fields: signers,
append: appendSigner,
remove: removeSigner,
replace,
move,
} = useFieldArray({
control,
name: 'signers',
});
const { getValues, watch, setValue } = useFormContext<TConfigureEmbedFormSchema>();
const signingOrder = watch('meta.signingOrder');
const { errors } = useFormState({
control,
});
const onAddSigner = useCallback(() => {
const signerNumber = signers.length + 1;
const recipientSigningOrder =
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
appendSigner({
formId: nanoid(8),
name: isTemplate ? `Recipient ${signerNumber}` : '',
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder:
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
});
}, [appendSigner, signers]);
const isSigningOrderEnabled = signingOrder === DocumentSigningOrder.SEQUENTIAL;
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
// Get current form values to preserve unsaved input data
const currentSigners = getValues('signers') || [...signers];
const signer = currentSigners[index];
// Remove signer from current position and insert at new position
const remainingSigners = currentSigners.filter((_: unknown, idx: number) => idx !== index);
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
remainingSigners.splice(newPosition, 0, signer);
// Update signing order for each item
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
...s,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
}));
// Update the form
replace(updatedSigners);
},
[signers, replace, getValues],
);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
// Use the move function from useFieldArray which preserves input values
move(result.source.index, result.destination.index);
// Update signing orders after move
const currentSigners = getValues('signers');
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
...signer,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
}));
// Update the form with new ordering
replace(updatedSigners);
},
[move, replace, getValues],
);
const onSigningOrderChange = (signingOrder: DocumentSigningOrder) => {
setValue('meta.signingOrder', signingOrder);
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
signers.forEach((_signer, index) => {
setValue(`signers.${index}.signingOrder`, index + 1);
});
}
};
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<Trans>Recipients</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Add signers and configure signing preferences</Trans>
</p>
<FormField
control={control}
name="meta.signingOrder"
render={({ field }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
onSigningOrderChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
}
disabled={isSubmitting}
/>
</FormControl>
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
</FormItem>
)}
/>
<FormField
control={control}
name="meta.allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
disabled={isSubmitting || !isSigningOrderEnabled}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence instead
of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
{signers.map((signer, index) => (
<Draggable
key={signer.id}
draggableId={signer.id}
index={index}
isDragDisabled={!isSigningOrderEnabled || isSubmitting || signer.disabled}
>
{(provided, snapshot) => (
<fieldset
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
disabled={signer.disabled}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
})}
>
<motion.div
className={cn('flex items-end gap-2 pb-2', {
'border-destructive/50': errors?.signers?.[index],
})}
>
{isSigningOrderEnabled && (
<FormField
control={control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('flex w-16 flex-none items-center gap-x-1', {
'mb-6':
errors?.signers?.[index] &&
!errors?.signers?.[index]?.signingOrder,
})}
>
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
min={1}
className="w-full text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
{...field}
disabled={isSubmitting || snapshot.isDragging}
onChange={(e) => {
field.onChange(e);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('flex-1', {
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.name,
})}
>
<FormLabel className="sr-only">
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
placeholder={_(msg`Name`)}
className="w-full"
{...field}
disabled={isSubmitting || snapshot.isDragging}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('flex-1', {
'mb-6':
errors?.signers?.[index] && !errors?.signers?.[index]?.email,
})}
>
<FormLabel className="sr-only">
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
type="email"
placeholder={_(msg`Email`)}
className="w-full"
{...field}
disabled={isSubmitting || snapshot.isDragging}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('flex-none', {
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.role,
})}
>
<FormLabel className="sr-only">
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderEnabled}
onValueChange={field.onChange}
disabled={isSubmitting || snapshot.isDragging || signer.disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
disabled={
isSubmitting ||
signers.length === 1 ||
snapshot.isDragging ||
signer.disabled
}
onClick={() => removeSigner(index)}
>
<Trash className="h-4 w-4" />
</Button>
</motion.div>
</fieldset>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="outline"
className="w-auto"
disabled={isSubmitting}
onClick={onAddSigner}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
<Trans>Add Signer</Trans>
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,238 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Cloud, FileText, Loader, X } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
export interface ConfigureDocumentUploadProps {
isSubmitting?: boolean;
}
export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocumentUploadProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { isPersisted } = useConfigureDocument();
const form = useFormContext<TConfigureEmbedFormSchema>();
const [isLoading, setIsLoading] = useState(false);
// Watch the documentData field from the form
const documentData = form.watch('documentData');
const onFileDrop = async (acceptedFiles: File[]) => {
try {
const file = acceptedFiles[0];
if (!file) {
return;
}
setIsLoading(true);
// Convert file to UInt8Array
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Store file metadata and UInt8Array in form data
form.setValue('documentData', {
name: file.name,
type: file.type,
size: file.size,
data: uint8Array, // Store as UInt8Array
});
// Auto-populate title if it's empty
const currentTitle = form.getValues('title');
if (!currentTitle) {
// Get filename without extension
const fileNameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
form.setValue('title', fileNameWithoutExtension);
}
} catch (error) {
console.error('Error uploading file', error);
toast({
title: _(msg`Error uploading file`),
description: _(msg`There was an error uploading your file. Please try again.`),
variant: 'destructive',
duration: 5000,
});
} finally {
setIsLoading(false);
}
};
const onDropRejected = () => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000,
variant: 'destructive',
});
};
const onRemoveFile = () => {
if (isPersisted) {
toast({
title: _(msg`Cannot remove document`),
description: _(msg`The document is already saved and cannot be changed.`),
duration: 5000,
variant: 'destructive',
});
return;
}
form.unregister('documentData');
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
maxSize: APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024,
multiple: false,
disabled: isSubmitting || isLoading || isPersisted,
onDrop: (files) => {
void onFileDrop(files);
},
onDropRejected,
});
return (
<div>
<FormField
control={form.control}
name="documentData"
render={() => (
<FormItem>
<FormLabel required>
<Trans>Upload Document</Trans>
</FormLabel>
<div className="relative">
{!documentData ? (
<div className="relative">
<FormControl>
<div
{...getRootProps()}
className={cn(
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
{
'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30':
!isDragActive && !isSubmitting && !isLoading && !isPersisted,
'cursor-not-allowed opacity-60': isSubmitting || isLoading || isPersisted,
},
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center gap-y-2 px-4 py-4 text-center">
<Cloud
className={cn('h-10 w-10', {
'text-primary': isDragActive,
'text-muted-foreground': !isDragActive,
})}
/>
<div
className={cn('flex flex-col space-y-1', {
'text-primary': isDragActive,
'text-muted-foreground': !isDragActive,
})}
>
<p className="text-sm font-medium">
{isDragActive ? (
<Trans>Drop your document here</Trans>
) : isPersisted ? (
<Trans>Document is already uploaded</Trans>
) : (
<Trans>Drag and drop or click to upload</Trans>
)}
</p>
<p className="text-xs">
{isPersisted ? (
<Trans>This document cannot be changed</Trans>
) : (
<Trans>
.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)
</Trans>
)}
</p>
</div>
</div>
</div>
</FormControl>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
</div>
)}
</div>
) : (
<div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
<FileText className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div>
<div className="text-muted-foreground text-xs">
{formatFileSize(documentData.size)}
</div>
</div>
{!isPersisted && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onRemoveFile}
disabled={isSubmitting}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};

View File

@ -0,0 +1,137 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from 'nanoid';
import { useForm } from 'react-hook-form';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { ConfigureDocumentAdvancedSettings } from './configure-document-advanced-settings';
import { useConfigureDocument } from './configure-document-context';
import { ConfigureDocumentRecipients } from './configure-document-recipients';
import { ConfigureDocumentUpload } from './configure-document-upload';
import {
type TConfigureEmbedFormSchema,
ZConfigureEmbedFormSchema,
} from './configure-document-view.types';
export interface ConfigureDocumentViewProps {
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
defaultValues?: Partial<TConfigureEmbedFormSchema>;
disableUpload?: boolean;
isSubmitting?: boolean;
}
export const ConfigureDocumentView = ({
onSubmit,
defaultValues,
disableUpload,
}: ConfigureDocumentViewProps) => {
const { isTemplate } = useConfigureDocument();
const form = useForm<TConfigureEmbedFormSchema>({
resolver: zodResolver(ZConfigureEmbedFormSchema),
defaultValues: {
title: defaultValues?.title || '',
signers: defaultValues?.signers || [
{
formId: nanoid(8),
name: isTemplate ? `Recipient ${1}` : '',
email: isTemplate ? `recipient.${1}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder: 1,
disabled: false,
},
],
meta: {
subject: defaultValues?.meta?.subject || '',
message: defaultValues?.meta?.message || '',
distributionMethod:
defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
emailSettings: defaultValues?.meta?.emailSettings || ZDocumentEmailSettingsSchema.parse({}),
dateFormat: defaultValues?.meta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT,
timezone: defaultValues?.meta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE,
redirectUrl: defaultValues?.meta?.redirectUrl || '',
language: defaultValues?.meta?.language || 'en',
signatureTypes: defaultValues?.meta?.signatureTypes || [],
signingOrder: defaultValues?.meta?.signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: defaultValues?.meta?.allowDictateNextSigner || false,
externalId: defaultValues?.meta?.externalId || '',
},
documentData: defaultValues?.documentData,
},
});
const { control, handleSubmit } = form;
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = handleSubmit(onSubmit);
return (
<div className="flex w-full flex-col space-y-8">
<div>
<h2 className="text-foreground mb-1 text-xl font-semibold">
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
</h2>
<p className="text-muted-foreground text-sm">
{isTemplate ? (
<Trans>Set up your template properties and recipient information</Trans>
) : (
<Trans>Set up your document properties and recipient information</Trans>
)}
</p>
</div>
<Form {...form}>
<div className="flex flex-col space-y-8">
<div>
<FormField
control={control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!disableUpload && <ConfigureDocumentUpload isSubmitting={isSubmitting} />}
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
<div className="flex justify-end">
<Button
type="button"
onClick={onFormSubmit}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
<Trans>Continue</Trans>
</Button>
</div>
</div>
</Form>
</div>
);
};

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