Compare commits

...

110 Commits

Author SHA1 Message Date
cc43139573 fix: downloaded files should have _signed (#1149)
Modifies the generated filename for the downloaded PDF by appending
"_signed" to the base title.

## Changes Made

- Update the filename in the downloadFile function call to append
"_signed" to the baseTitle variable.

## Testing Performed

Tested the `downloadPDF` function locally to ensure that the downloaded
PDF file has the correct filename with "_signed" appended.
2024-06-05 14:56:02 +10:00
0c2306b745 fix: add correct role names for direct templates (#1179)
## Description

Update the direct template signing process and emails to correctly
reflect the role of the recipient who actioned the direct template.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Dynamic updating of title and description based on recipient role in
the document signing process.
- Enhanced email templates to include recipient roles, providing more
context in email notifications.

- **Improvements**
- More descriptive actions in email templates based on recipient roles,
improving clarity for recipients.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-06-05 14:40:45 +10:00
d11a68fc4c feat: add direct templates links (#1165)
## Description

Direct templates links is a feature that provides template owners the
ability to allow users to create documents based of their templates.

## General outline

This works by allowing the template owner to configure a "direct
recipient" in the template.

When a user opens the direct link to the template, it will create a flow
where they sign the fields configured by the template owner for the
direct recipient. After these fields are signed the following will
occur:

- A document will be created where the owner is the template owner
- The direct recipient fields will be signed
- The document will be sent to any other recipients configured in the
template
- If there are none the document will be immediately completed

## Notes

There's a custom prisma migration to migrate all documents to have
'DOCUMENT' as the source, then sets the column to required.

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-06-02 15:49:09 +10:00
c346a3fd6a feat: add oidc support (#1103)
## Description

This PR adds generic OIDC as an authentication provider. This allows
personal users and companies potentially to define whatever IdP they
want as long as it supports the OIDC well known format. (Azure, Zitadel,
Authentik, KeyCloak, Google, etc. all support it)

## Related Issue

Fixes #1090 

## Changes Made

- Adds OIDC buttons to the signin and registration pages
- Adds appropriate environment variables
- Adds migration to add OIDC to the `IdentityProvider` Enum

## Testing Performed

#### Zitadel
- Created application in Zitadel as an web app, with Client auth
- Enabled `User Info inside ID Token` in Token settings
- Copied client id and client secret to the new .ENV variables
- Copied the well-known URL from the URLs section to .ENV
- Created new account with OIDC provider button
- Verified email manually
- Signed into account with OIDC provider
- Logged out
- Signed into accounting again with OIDC provider

#### Authentik
- Created application in Authentik
- Copied client id and client secret to the new .ENV variables
- Copied the well-known URL from the URLs section to .ENV
- Created new account with OIDC provider button
- Verified email manually
- Signed into account with OIDC provider
- Logged out
- Signed into accounting again with OIDC provider

#### Azure AD
- Created application in Azure AD using OAuth2
- Copied client id and client secret to the new .ENV variables
- Copied the well-known URL from the URLs section to .ENV
- Created new account with OIDC provider button
- Verified email manually
- Signed into account with OIDC provider
- Logged out
- Signed into accounting again with OIDC provider
2024-05-31 11:45:17 +10:00
70eeb1a746 chore: improve oidc provider support
Adds fields to the Account model to support various pieces
of data returned by OIDC providers such as AzureAD and GitLab.

Additionally passes through the email verification status and handles
retrieving the email for providers such as AzureAD who use a different
claim instead.
2024-05-30 22:15:45 +10:00
d8d0734680 Merge branch 'main' into mk/oidc-auth 2024-05-30 15:41:36 +10:00
6bd2f68014 fix: Capitalise "Stripe" (#1173)
Small fix, I noticed on the marketing site
2024-05-30 13:33:01 +10:00
ede6eea88d feat: add kysely for raw type-safe SQL queries (#1041) 2024-05-29 22:46:20 +10:00
ebc547684a fix: inline the remember package 2024-05-29 22:25:23 +10:00
5724e73d49 chore: revert find-documents change for now
Reverts the change to find-documents to use Kysely. I'd like to gain
confidence by using it in smaller pieces before commiting to doing
what is one of our most complicated queries in Documenso.
2024-05-29 20:54:43 +10:00
4a6b5ceaf8 fix: re-add removed types 2024-05-29 20:53:50 +10:00
ab949afbb6 fix: convert to kysely queries 2024-05-29 20:03:51 +10:00
3d81b15d71 chore: tidy code 2024-05-29 14:47:33 +10:00
27fe8c7f8f Merge branch 'main' into feat/add-kysely 2024-05-28 14:53:29 +10:00
0c18f27b3f feat: remove the existing empty signer if its the only one (#1127)
Removes the existing empty signer if its the only one
2024-05-28 14:15:01 +10:00
b394e99f7a Merge branch 'main' into feat/start-selfSign 2024-05-28 12:53:57 +10:00
c21e30d689 chore: tidy code 2024-05-28 02:45:57 +00:00
9b92e38c52 chore: add more tests (#1079) 2024-05-27 11:17:03 +07:00
ac41086e1a Merge branch 'main' into feat/start-selfSign 2024-05-24 23:48:20 +10:00
94cf412f29 fix: show team url in dropdown menu on hover (#1122)
fixes: #943 

<img width="330" alt="Screenshot 2024-02-19 211732"
src="https://github.com/documenso/documenso/assets/75713174/724078ca-e107-4acb-a75d-c7d2cdd29b80">


Video Link:
https://www.loom.com/share/35328504cf3f46e9be78bd485252e8dc?sid=1f309776-8b52-4af4-b86b-652b762fef5b
2024-05-24 23:46:57 +10:00
6650a1d72e feat: optional email sending for api users
Introduces the ability to not send an email when sending
(publishing) a document using the API.

Additionally returns the signing link for each recipient
when working with recipient API endpoints and returns
the document object including recipients when sending
documents via API.
2024-05-24 23:36:28 +10:00
82848e3d2e fix: animate transition 2024-05-24 18:47:03 +10:00
22b8c2044b Merge branch 'main' into fix/show-teams-url-new 2024-05-24 14:58:42 +10:00
ef5d267e96 fix: Remove document on go back click on step 1 (#910)
 Fixes #903 
 Invoke `onBackStep` on "Go Back" click and conditionally render
Go back label string.
2024-05-24 14:17:53 +10:00
518ddea081 feat: pin input component (#936)
https://github.com/documenso/documenso/assets/55143799/fa3d14d6-59e6-4984-9287-7375198fcea0
2024-05-24 14:13:28 +10:00
805758f716 Merge branch 'main' into reattach-pdf 2024-05-24 14:07:43 +10:00
04ebb26a0b chore: update wording 2024-05-24 04:02:58 +00:00
9cb80aa0bc chore: add pin input to all 2FA components
Adds the pin input to the currently used 2FA components sunsetting
the standard input that was previously used.
2024-05-24 03:31:19 +00:00
0985206088 Merge branch 'main' into 2fa-input 2024-05-24 12:53:41 +10:00
aadb22cdbf fix: use shadcn pin input and revert changes 2024-05-24 02:51:25 +00:00
3e304b37b2 feat: sealing robustness (#1170)
A series of changes to improve sealing robustness avoiding errors that
have appeared during monitoring.

Additionally handles the recently published CVE affecting the
`pdfjs-dist` library.
2024-05-23 15:35:05 +10:00
1f3df51371 fix: update font variable typo 2024-05-23 15:13:12 +10:00
6e2363d48c Merge branch 'main' into fix/sealing-robustness 2024-05-23 15:11:23 +10:00
64bec5f29c fix: remove console.log statements 2024-05-23 15:10:28 +10:00
311328471e fix: bump react-pdf and pdfjs-dist to handle cve
Bumps ReactPDF and pdfjs-dist to avoid the CVE that allows
for code execution in pdf's. This change doesn't specifically
upgrade to the latest pdfjs-dist due to issues with top level
await, instead disabling the evaluation of javascript within
the PDF.
2024-05-23 14:47:32 +10:00
d58a88196a fix: use noto sans for text insertion on pdfs
Use Noto Sans to gracefully handle inserting custom text
on PDF's. Previously we were using Helvetica which is a
standard PDF font but that would fail for any character
that couldn't be encoded in WinANSI.

Noto Sans was chosen as it has support for a large number
of languages and glyphs with challenges now being adding
support for CJK glyphs.
2024-05-23 13:07:37 +10:00
f1c6fc6fb7 chore: update gitpod config (#1151)
## Description
Remove deprecated config from `gitpod.yaml`

The output when it is run in Gitpod

```
HISTFILE=/workspace/.gitpod/cmd-0 history -r; {
npm i &&
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)"

} && {
npm run d
}
gitpod /workspace/documenso (chore/update-gitpod) $  HISTFILE=/workspace/.gitpod/cmd-0 history -r; {
> npm i &&
> 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)"
> 
> } && {
> npm run d
> }

> prepare
> husky install

install command is deprecated

added 1842 packages, and audited 1859 packages in 1m

438 packages are looking for funding
  run `npm fund` for details

16 vulnerabilities (1 low, 11 moderate, 3 high, 1 critical)

To address issues that do not require attention, run:
  npm audit fix

To address all issues possible (including breaking changes), run:
  npm audit fix --force

Some issues need review, and may require choosing
a different dependency.

Run `npm audit` for details.
npm notice 
npm notice New minor version of npm available! 10.5.2 -> 10.8.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v10.8.0
npm notice Run npm install -g npm@10.8.0 to update!
npm notice 

> dx:up
> docker compose -f docker/development/compose.yml up -d

[+] Running 33/3
 ✔ minio Pulled                                                                                                                                          6.6s 
 ✔ database Pulled                                                                                                                                      11.3s 
 ✔ inbucket Pulled                                                                                                                                       6.1s 
[+] Running 5/5
 ✔ Network documenso-development_default  Created                                                                                                        0.1s 
 ✔ Volume "documenso-development_minio"   Created                                                                                                        0.0s 
 ✔ Container database                     Started                                                                                                        1.0s 
 ✔ Container minio                        Started                                                                                                        1.0s 
 ✔ Container mailserver                   Started                                                                                                        1.0s 

> d
> npm run dx && npm run dev


> dx
> npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed


> prepare
> husky install

install command is deprecated

up to date, audited 1859 packages in 4s

438 packages are looking for funding
  run `npm fund` for details

18 vulnerabilities (1 low, 11 moderate, 5 high, 1 critical)

To address issues that do not require attention, run:
  npm audit fix

To address all issues possible (including breaking changes), run:
  npm audit fix --force

Some issues need review, and may require choosing
a different dependency.

Run `npm audit` for details.

> dx:up
> docker compose -f docker/development/compose.yml up -d

[+] Running 3/0
 ✔ Container mailserver  Running                                                                                                                         0.0s 
 ✔ Container minio       Running                                                                                                                         0.0s 
 ✔ Container database    Running                                                                                                                         0.0s 

> prisma:migrate-dev
> npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma


> with:env
> dotenv -e .env -e .env.local -- npm run prisma:migrate-dev -w @documenso/prisma


> @documenso/prisma@1.0.0 prisma:migrate-dev
> prisma migrate dev --skip-seed

Prisma schema loaded from schema.prisma
Datasource "db": PostgreSQL database "documenso", schema "public" at "127.0.0.1:54320"

Applying migration `20230404095503_initial_migration`
Applying migration `20230411134605_doc_208`
Applying migration `20230421134018_doc_214_add_signed_at_field`
Applying migration `20230505085625_create_subscription_table`
Applying migration `20230505085908_update_unique_constraint`
Applying migration `20230505091928_add_past_due_value`
Applying migration `20230605122017_password_reset`
Applying migration `20230605164015_expire_password_reset_token`
Applying migration `20230617040606_add_name_field`
Applying migration `20230617041623_add_email_field`
Applying migration `20230621130930_add_width_and_height_for_fields`
Applying migration `20230621131348_add_document_id_and_email_index`
Applying migration `20230621133446_migrate_field_position_to_float`
Applying migration `20230829165148_user_sharing_link`
Applying migration `20230829180915_recipient_id_for_user_id`
Applying migration `20230830053354_add_service_user`
Applying migration `20230901083000_add_user_signature_column`
Applying migration `20230907041233_add_document_data_table`
Applying migration `20230907074451_insert_old_data_into_document_data_table`
Applying migration `20230907075057_user_roles`
Applying migration `20230907080056_add_created_at_and_updated_at_columns`
Applying migration `20230907082622_remove_old_document_data`
Applying migration `20230912011344_reverse_document_data_relation`
Applying migration `20230914031347_remove_redundant_created_column`
Applying migration `20230917190854_password_reset_token`
Applying migration `20230918111438_update_subscription_constraints_and_columns`
Applying migration `20230920052232_document_meta`
Applying migration `20230920060743_update_share_link_schema`
Applying migration `20230920124941_fix_documentmeta_relation`
Applying migration `20230922121421_fix_document_meta_schema`
Applying migration `20231013012902_add_document_share_link_delete_cascade`
Applying migration `20231025074705_add_email_confirmation_registration`
Applying migration `20231028094931_add_user_timestamp_columns`
Applying migration `20231028095542_use_now_for_last_signed_in`
Applying migration `20231030055821_add_database_indexes`
Applying migration `20231031072857_verify_existing_users`
Applying migration `20231103044612_add_completed_date`
Applying migration `20231105184518_add_2fa`
Applying migration `20231123132053_public_api_api_token`
Applying migration `20231202134005_deletedocuments`
Applying migration `20231202220928_add_recipient_roles`
Applying migration `20231205000309_add_cascade_delete_for_verification_tokens`
Applying migration `20231206073509_add_multple_subscriptions`
Applying migration `20231207134820_add_document_meta_dateformat_timezone`
Applying migration `20231220124343_add_cascade_delete_user_apitoken`
Applying migration `20231221101005_add_templates`
Applying migration `20240115031508_add_password_to_document_meta`
Applying migration `20240131004516_add_user_security_audit_logs`
Applying migration `20240131132916_verify_paid_users`
Applying migration `20240205040421_add_teams`
Applying migration `20240205120648_create_delete_account`
Applying migration `20240206051948_add_teams_templates`
Applying migration `20240206111230_add_document_meta_redirect_url`
Applying migration `20240206131417_add_user_webhooks`
Applying migration `20240208135802_make_expiry_date_optional_api_tokens`
Applying migration `20240209023519_add_document_audit_logs`
Applying migration `20240220115435_add_public_profile_url_bio`
Applying migration `20240221055920_support_team_tokens`
Applying migration `20240222183156_display_banner`
Applying migration `20240222183231_banner_show`
Applying migration `20240222185936_remove_custom`
Applying migration `20240222230527_change_banner_to_site_settings_model`
Applying migration `20240222230604_add_site_banner_to_site_settings`
Applying migration `20240224085633_extend_webhook_trigger_events`
Applying migration `20240226035048_add_recipient_referential_action_for_fields`
Applying migration `20240227003622_migrate_to_cuids_for_webhooks`
Applying migration `20240227015420_add_webhook_call_table`
Applying migration `20240227023747_add_team_webhooks`
Applying migration `20240227031228_add_event_to_webhook_call_model`
Applying migration `20240227111633_rework_user_profiles`
Applying migration `20240306060259_add_passkeys`
Applying migration `20240311113243_add_document_auth`
Applying migration `20240327074701_add_secondary_verification_id`
Applying migration `20240408083413_add_form_values_column`
Applying migration `20240408142543_add_recipient_document_delete`
Applying migration `20240418140819_remove_impossible_field_optional_states`
Applying migration `20240424072655_update_foreign_key_constraints`

The following migration(s) have been applied:

migrations/
  └─ 20230404095503_initial_migration/
    └─ migration.sql
  └─ 20230411134605_doc_208/
    └─ migration.sql
  └─ 20230421134018_doc_214_add_signed_at_field/
    └─ migration.sql
  └─ 20230505085625_create_subscription_table/
    └─ migration.sql
  └─ 20230505085908_update_unique_constraint/
    └─ migration.sql
  └─ 20230505091928_add_past_due_value/
    └─ migration.sql
  └─ 20230605122017_password_reset/
    └─ migration.sql
  └─ 20230605164015_expire_password_reset_token/
    └─ migration.sql
  └─ 20230617040606_add_name_field/
    └─ migration.sql
  └─ 20230617041623_add_email_field/
    └─ migration.sql
  └─ 20230621130930_add_width_and_height_for_fields/
    └─ migration.sql
  └─ 20230621131348_add_document_id_and_email_index/
    └─ migration.sql
  └─ 20230621133446_migrate_field_position_to_float/
    └─ migration.sql
  └─ 20230829165148_user_sharing_link/
    └─ migration.sql
  └─ 20230829180915_recipient_id_for_user_id/
    └─ migration.sql
  └─ 20230830053354_add_service_user/
    └─ migration.sql
  └─ 20230901083000_add_user_signature_column/
    └─ migration.sql
  └─ 20230907041233_add_document_data_table/
    └─ migration.sql
  └─ 20230907074451_insert_old_data_into_document_data_table/
    └─ migration.sql
  └─ 20230907075057_user_roles/
    └─ migration.sql
  └─ 20230907080056_add_created_at_and_updated_at_columns/
    └─ migration.sql
  └─ 20230907082622_remove_old_document_data/
    └─ migration.sql
  └─ 20230912011344_reverse_document_data_relation/
    └─ migration.sql
  └─ 20230914031347_remove_redundant_created_column/
    └─ migration.sql
  └─ 20230917190854_password_reset_token/
    └─ migration.sql
  └─ 20230918111438_update_subscription_constraints_and_columns/
    └─ migration.sql
  └─ 20230920052232_document_meta/
    └─ migration.sql
  └─ 20230920060743_update_share_link_schema/
    └─ migration.sql
  └─ 20230920124941_fix_documentmeta_relation/
    └─ migration.sql
  └─ 20230922121421_fix_document_meta_schema/
    └─ migration.sql
  └─ 20231013012902_add_document_share_link_delete_cascade/
    └─ migration.sql
  └─ 20231025074705_add_email_confirmation_registration/
    └─ migration.sql
  └─ 20231028094931_add_user_timestamp_columns/
    └─ migration.sql
  └─ 20231028095542_use_now_for_last_signed_in/
    └─ migration.sql
  └─ 20231030055821_add_database_indexes/
    └─ migration.sql
  └─ 20231031072857_verify_existing_users/
    └─ migration.sql
  └─ 20231103044612_add_completed_date/
    └─ migration.sql
  └─ 20231105184518_add_2fa/
    └─ migration.sql
  └─ 20231123132053_public_api_api_token/
    └─ migration.sql
  └─ 20231202134005_deletedocuments/
    └─ migration.sql
  └─ 20231202220928_add_recipient_roles/
    └─ migration.sql
  └─ 20231205000309_add_cascade_delete_for_verification_tokens/
    └─ migration.sql
  └─ 20231206073509_add_multple_subscriptions/
    └─ migration.sql
  └─ 20231207134820_add_document_meta_dateformat_timezone/
    └─ migration.sql
  └─ 20231220124343_add_cascade_delete_user_apitoken/
    └─ migration.sql
  └─ 20231221101005_add_templates/
    └─ migration.sql
  └─ 20240115031508_add_password_to_document_meta/
    └─ migration.sql
  └─ 20240131004516_add_user_security_audit_logs/
    └─ migration.sql
  └─ 20240131132916_verify_paid_users/
    └─ migration.sql
  └─ 20240205040421_add_teams/
    └─ migration.sql
  └─ 20240205120648_create_delete_account/
    └─ migration.sql
  └─ 20240206051948_add_teams_templates/
    └─ migration.sql
  └─ 20240206111230_add_document_meta_redirect_url/
    └─ migration.sql
  └─ 20240206131417_add_user_webhooks/
    └─ migration.sql
  └─ 20240208135802_make_expiry_date_optional_api_tokens/
    └─ migration.sql
  └─ 20240209023519_add_document_audit_logs/
    └─ migration.sql
  └─ 20240220115435_add_public_profile_url_bio/
    └─ migration.sql
  └─ 20240221055920_support_team_tokens/
    └─ migration.sql
  └─ 20240222183156_display_banner/
    └─ migration.sql
  └─ 20240222183231_banner_show/
    └─ migration.sql
  └─ 20240222185936_remove_custom/
    └─ migration.sql
  └─ 20240222230527_change_banner_to_site_settings_model/
    └─ migration.sql
  └─ 20240222230604_add_site_banner_to_site_settings/
    └─ migration.sql
  └─ 20240224085633_extend_webhook_trigger_events/
    └─ migration.sql
  └─ 20240226035048_add_recipient_referential_action_for_fields/
    └─ migration.sql
  └─ 20240227003622_migrate_to_cuids_for_webhooks/
    └─ migration.sql
  └─ 20240227015420_add_webhook_call_table/
    └─ migration.sql
  └─ 20240227023747_add_team_webhooks/
    └─ migration.sql
  └─ 20240227031228_add_event_to_webhook_call_model/
    └─ migration.sql
  └─ 20240227111633_rework_user_profiles/
    └─ migration.sql
  └─ 20240306060259_add_passkeys/
    └─ migration.sql
  └─ 20240311113243_add_document_auth/
    └─ migration.sql
  └─ 20240327074701_add_secondary_verification_id/
    └─ migration.sql
  └─ 20240408083413_add_form_values_column/
    └─ migration.sql
  └─ 20240408142543_add_recipient_document_delete/
    └─ migration.sql
  └─ 20240418140819_remove_impossible_field_optional_states/
    └─ migration.sql
  └─ 20240424072655_update_foreign_key_constraints/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (v5.4.2) to ./../../node_modules/@prisma/client in 433ms



> prisma:seed
> npm run with:env -- npm run prisma:seed -w @documenso/prisma


> with:env
> dotenv -e .env -e .env.local -- npm run prisma:seed -w @documenso/prisma


> @documenso/prisma@1.0.0 prisma:seed
> prisma db seed

Running seed command `ts-node --transpileOnly --project ./tsconfig.seed.json ./seed-database.ts` ...
[SEEDING]: initial-seed.ts
Database seeded

🌱  The seed command has been executed.
┌─────────────────────────────────────────────────────────┐
│  Update available 5.4.2 -> 5.14.0                       │
│  Run the following to update                            │
│    npm i --save-dev prisma@latest                       │
│    npm i @prisma/client@latest                          │
└─────────────────────────────────────────────────────────┘

> dev
> turbo run dev --filter=@documenso/web --filter=@documenso/marketing

Turborepo did not find the correct binary for your platform.
We will attempt to install it now.
Installation has succeeded.
• Packages in scope: @documenso/marketing, @documenso/web
• Running dev in 2 packages
• Remote caching disabled
@documenso/marketing:dev: cache bypass, force executing 8e2b04584367b8ee
@documenso/web:dev: cache bypass, force executing 62825fff83b7cfc4
@documenso/marketing:dev: 
@documenso/marketing:dev: > @documenso/marketing@1.2.3 dev
@documenso/marketing:dev: > next dev -p 3001
@documenso/marketing:dev: 
@documenso/web:dev: 
@documenso/web:dev: > @documenso/web@1.2.3 dev
@documenso/web:dev: > next dev -p 3000
@documenso/web:dev: 
@documenso/web:dev:    ▲ Next.js 14.0.3
@documenso/web:dev:    - Local:        http://localhost:3000
@documenso/web:dev:    - Experiments (use at your own risk):
@documenso/web:dev:      · outputFileTracingRoot
@documenso/web:dev: 
@documenso/marketing:dev:    ▲ Next.js 14.0.3
@documenso/marketing:dev:    - Local:        http://localhost:3001
@documenso/marketing:dev:    - Experiments (use at your own risk):
@documenso/marketing:dev:      · outputFileTracingRoot
@documenso/marketing:dev: 
@documenso/web:dev: Attention: Next.js now collects completely anonymous telemetry regarding usage.
@documenso/web:dev: This information is used to shape Next.js' roadmap and prioritize features.
@documenso/web:dev: You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
@documenso/web:dev: https://nextjs.org/telemetry
@documenso/web:dev: 
@documenso/web:dev:  ✓ Ready in 1868ms
@documenso/marketing:dev: Contentlayer config change detected. Updating type definitions and data...
@documenso/marketing:dev:  ✓ Ready in 2.9s
@documenso/marketing:dev:  ○ Compiling / ...
@documenso/marketing:dev: Generated 28 documents in .contentlayer
@documenso/marketing:dev: Browserslist: caniuse-lite is outdated. Please run:
@documenso/marketing:dev:   npx update-browserslist-db@latest
@documenso/marketing:dev:   Why you should do it regularly: https://github.com/browserslist/update-db#readme
@documenso/marketing:dev:  ✓ Compiled / in 10.8s (2046 modules)
@documenso/web:dev:  ○ Compiling /src/middleware ...
@documenso/web:dev:  ✓ Compiled /src/middleware in 973ms (257 modules)
@documenso/web:dev:  ○ Compiling /documents ...
@documenso/web:dev: Browserslist: caniuse-lite is outdated. Please run:
@documenso/web:dev:   npx update-browserslist-db@latest
@documenso/web:dev:   Why you should do it regularly: https://github.com/browserslist/update-db#readme
@documenso/web:dev:  ✓ Compiled /documents in 17.7s (4376 modules)
@documenso/web:dev: *********************************************************************
@documenso/web:dev: *
@documenso/web:dev: *
@documenso/web:dev: Please change the encryption key from the default value of "CAFEBABE"
@documenso/web:dev: *
@documenso/web:dev: *
@documenso/web:dev: *********************************************************************
@documenso/web:dev:  ○ Compiling /signin ...
@documenso/web:dev:  ✓ Compiled /signin in 10.3s (4380 modules)
@documenso/web:dev: *********************************************************************
@documenso/web:dev: *
@documenso/web:dev: *
@documenso/web:dev: Please change the encryption key from the default value of "CAFEBABE"
@documenso/web:dev: *
@documenso/web:dev: *
@documenso/web:dev: *********************************************************************

```
2024-05-23 10:06:11 +07:00
babdbccbd3 chore: change default sender name to match prod (#1161)
change the default sender to sth. nicer
2024-05-22 19:19:29 +07:00
3e634fd975 chore: update docker compose command (#1159) 2024-05-22 19:15:31 +07:00
4c0b772fc9 fix: rewrite form flattening handler
Previously we used the form flattening method from PDF-Lib
but unfortunately when it encountered orphaned form items
or other PDF oddities it would throw an error.

Because of this certain documents would fail to seal and
be stuck in a pending state with no recourse available.
This change rewrites the form flattening handler to be
more lenient when coming across the unknown opting to skip
items it can't handle rather than abort.
2024-05-22 21:58:30 +10:00
24b228acf7 feat: show time in documents table (#1123)
---
name: Pull Request
about: Submit changes to the project for review and inclusion
---

## Description

Display time in 12h format in the documents table.
<!--- Describe the changes introduced by this pull request. -->
<!--- Explain what problem it solves or what feature/fix it adds. -->

## Related Issue

<!--- If this pull request is related to a specific issue, reference it
here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->
Fixes #1077 
## Changes Made

<!--- Provide a summary of the changes made in this pull request. -->
<!--- Include any relevant technical details or architecture changes.
-->

- Use DateTime.DATETIME_SHORT
- ...

## Testing Performed

<!--- Describe the testing that you have performed to validate these
changes. -->
<!--- Include information about test cases, testing environments, and
results. -->

- Tested time in different timezone.
- Ran tests in web and mobile browser

1. Login 
2. Add a document
3. Verify that you see date and time in 12h format as per your locale.

## 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.
- [x] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes
<img width="1512" alt="Screenshot 2024-04-29 at 04 21 53"
src="https://github.com/documenso/documenso/assets/29673073/778155d4-a920-40bd-acdc-7451c9c5d4b7">


<img width="300" alt="Screenshot 2024-04-29 at 04 22 18"
src="https://github.com/documenso/documenso/assets/29673073/3471de0f-f426-4ea1-be1e-220462aff9e4">
2024-05-21 14:14:11 +02:00
e072e270f8 Merge branch 'main' into show-time 2024-05-21 14:19:27 +05:30
d37edc4351 fix: syntax in production compose.yml (#1143) 2024-05-20 12:51:56 +10:00
a877c64aca Merge branch 'main' into show-time 2024-05-12 21:07:15 +05:30
2f86bb523b feat: add template enhancements (#1154)
## Description

General enhancements for templates.

## Changes Made

Added the following changes to the template flow:
- Allow adding document meta settings
- Allow adding email settings
- Allow adding document access & action authentication
- Allow adding recipient action authentication
- Save the state between template steps similar to how it works for
documents

Other changes:
- Extract common fields between document and template flows
- Remove the title field from "Use template" since we now have it as
part of the template flow
- Add new API endpoint for generating templates

## Testing Performed

Added E2E tests for templates and creating documents from templates
2024-05-10 19:45:19 +07:00
788933b75d Merge branch 'main' into show-time 2024-05-08 19:18:24 +05:30
bbcbc56e70 feat: 12h format 2024-05-08 19:17:47 +05:30
8f9c07aa8e chore: updated triage label (#1152)
Description:

This PR updates the triage label for new issues
2024-05-08 17:56:23 +05:30
cc4efddabf chore: updated triage label 2024-05-08 17:03:57 +05:30
98672560ca chore: update self signer logic
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-08 17:02:01 +05:30
6f6ed05569 Merge branch 'feat/start-selfSign' of https://github.com/documenso/documenso into feat/start-selfSign 2024-05-08 15:35:43 +05:30
5e3f55c616 Merge branch 'main' of https://github.com/documenso/documenso into feat/start-selfSign 2024-05-08 15:34:18 +05:30
968b116012 Merge branch 'main' into show-time 2024-05-08 15:27:30 +05:30
2ba0f48c61 fix: unauthorized access error api tokens page team (#1134) 2024-05-08 12:03:21 +07:00
5d5d0210fa chore: update github actions (#1085)
**Description:**

This PR updates and adds a new action to assign `status: assigned` label

---------

Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-08 11:52:26 +07:00
e50ccca766 fix: allow template recipients to be filled (#1148)
## Description

Update the template flow to allow for entering recipient placeholder
emails and names

## Changes Made

- General refactoring
- Added advanced recipient settings for future usage
2024-05-07 17:22:24 +07:00
f363dee761 fix: downloaded files should have _signed 2024-05-07 10:19:09 +00:00
d7a3c40050 feat: add general template enhancements (#1147)
## Description

Refactor the "use template" flow

## Changes Made

- Add placeholders for recipients
- Add audit log when document is created
- Trigger DOCUMENT_CREATED webhook when document is created
- Remove role field when using template
- Remove flaky logic when associating template recipients with form
recipients
- Refactor to use `Form` 

### Using template when document has no recipients

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/a8494ac9-0397-4e3b-a0cf-818c8454a55c">

### Using template with recipients 

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/54d949fc-ed6a-4318-bfd6-6a3179896ba9">

### Using template with the send option selected

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/541b2664-0540-43e9-83dd-e040a45a44ea">
2024-05-07 15:04:12 +07:00
dc11676d28 fix: profile claim name length (#1144)
fixes the caim name length on the profile claim popup
2024-05-07 14:42:16 +07:00
e8d4fe46e5 fix: custom email message for self-signers (#1120) 2024-05-06 09:22:50 +03:00
55d8afe870 Merge branch 'main' into feat/start-selfSign 2024-05-06 11:37:16 +05:30
64e3e2c64b fix: disable encrypted pdfs (#1130)
## Description

Currently if you complete a pending encrypted document, it will prevent
the document from being sealed due to the systems inability to decrypt
it.

This PR disables uploading any documents that cannot be loaded as a
temporary measure.

**Note**
This is a client side only check

## Changes Made

- Disable uploading documents that cannot be parsed
- Refactor putFile to putDocumentFile
- Add a flag as a backup incase something goes wrong
2024-05-03 22:25:24 +07:00
e4620efa4a fix syntax in production compose.yml 2024-05-03 14:48:39 +02:00
84bbcea7bb Merge branch 'main' into show-time 2024-05-03 12:29:23 +05:30
15dee5ef35 fix: enforce users to have stripe account (#1131)
## Description

Currently users who sign in via Google SSO do not get assigned a Stripe
customer account.

This enforces the Stripe customer requirement on sign in.

There might be a better place to put this so it's open to any
suggestions.
2024-05-01 16:48:05 +10:00
28d6f6e2e8 fix: improve sealing process (#1133)
## Description

Improves the sealing process by being strict on how long certificate
generation can take, opting to fail generation and continue sealing.

Also changes the ordering of sealing so an error in the process won't
also cause a document to be "COMPLETED" since it hasn't been
cryptographically sealed yet.

The downside to this change is that documents that fail during sealing
will require manual intervention as a signer or owner won't be able to
*complete* the document.

## Testing Performed

- Modified code to force specific failure modes to occur and verified
that documents were either gracefully sealed without a certificate or
not sealed and not completed.
2024-05-01 16:47:11 +10:00
78dc57a6eb fix: improvements from review 2024-05-01 16:16:04 +10:00
d3528f74f0 fix: improve sealing process
Improves the sealing process by being strict on how
long certificate generation can take, opting to fail
generation and continue sealing.

Also changes the ordering of sealing so an error in the
process won't also cause a document to be "COMPLETED"
since it hasn't been cryptographically sealed yet.

The downside to this change is that documents that fail
during sealing will require manual intervention as a signer
or owner won't be able to *complete* the document.
2024-05-01 14:18:01 +10:00
dbd452be97 fix: delete pending documents (#1118)
## Description

Currently deleting a pending document where you are a recipient off will
delete the document, but will also throw an error.

This is due to the recipient being updated after the document deleted,
which is only supposed to happen for completed documents.
2024-04-30 20:53:18 +07:00
5109bb17d6 chore: fix button styling (#1132)
**Description:**

This PR fixes the button styling issue

**Before:**


![image](https://github.com/documenso/documenso/assets/23498248/0af045aa-3714-48d8-9c22-6cd171b07079)

**After:**

<img width="1280" alt="Screenshot 2024-04-30 at 6 48 47 PM"
src="https://github.com/documenso/documenso/assets/23498248/e7dd99de-60fc-4cc2-aefc-21b130aa0116">
2024-04-30 18:57:29 +05:30
6974a76ed4 chore: fix button styling 2024-04-30 18:47:49 +05:30
5efb0894e6 chore: updated dark mode text (#1129)
Description:

This PR updates the dark mode text for this article,
https://app.documenso.com/articles/signature-disclosure
2024-04-30 17:14:28 +05:30
cfec366c1a fix: refactor 2024-04-30 15:54:24 +07:00
8622e68853 fix: add logging 2024-04-30 15:50:22 +07:00
6df525b670 feat: updated signer logic
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-30 12:05:42 +05:30
0e16a86e74 chore: updated dark mode text 2024-04-30 11:55:01 +05:30
dca4b8eaec Merge branch 'main' into show-time 2024-04-30 09:31:42 +05:30
db9e605031 chore: fix lint issues
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-30 01:32:58 +05:30
97d334a1da fix: force users to have a Stripe customer on sign in 2024-04-29 20:15:40 +07:00
bde0f5893f feat: update add self signer logic 2024-04-29 17:49:50 +05:30
6b5750c7bf chore: revert previous changes 2024-04-29 17:48:00 +05:30
917c83fc5f chore: refactor removal logic 2024-04-29 17:30:01 +05:30
e82e402540 feat: remove the existing empty signer if its the only one 2024-04-29 17:10:56 +05:30
345e42537a fix: include all document meta when using the public api 2024-04-29 12:42:22 +10:00
80c03fcf3f feat: show time in documents table 2024-04-29 04:28:13 +05:30
c98c1b9467 added teams url under a team name in teams section 2024-04-28 19:35:57 +05:30
788c6269a2 fix: signup page oidc function 2024-04-13 21:16:39 -04:00
bd4a1c4c09 fix: update .env.example 2024-04-13 21:06:24 -04:00
e0440fd8a2 feat: add oidc support 2024-04-13 20:46:08 -04:00
02921e53de chore: trying to fix the issues 2024-04-04 10:51:41 +03:00
60c26a9f75 chore: finished converting to kysely 2024-04-03 14:53:40 +03:00
7f7e7da3af chore: format the final query data and return it 2024-04-03 11:23:19 +03:00
82792864de chore: remove unintended console logs 2024-04-02 16:18:07 +03:00
409d8aa5a2 chore: almost done? 2024-04-02 16:13:03 +03:00
f520e0a7a6 chore: converting to kysely 2024-03-29 17:23:42 +02:00
462e1348a8 chore: test queries 2024-03-28 12:02:51 +02:00
6b73899ecc chore: re-arrange stuff 2024-03-21 15:46:53 +02:00
fdbac9fc03 feat: update next-auth-options to use the kysely adapter 2024-03-21 15:07:05 +02:00
5e8d93f24b feat: add kysely for raw type-safe SQL queries 2024-03-20 15:42:17 +02:00
870de02efa Merge branch 'main' into reattach-pdf 2024-03-01 21:23:17 +05:30
a58a117056 Merge branch 'main' into reattach-pdf 2024-02-23 23:58:58 +05:30
918e9ddc0b chore: use token input on enable 2fa 2024-02-16 21:20:16 +00:00
94eee8b913 chore: change font family 2024-02-16 20:49:52 +00:00
345c4b8b14 feat: use pin-input on sign in 2024-02-15 16:00:13 +00:00
897f0dabde feat: 2fa pin input component 2024-02-15 14:21:40 +00:00
d5867ae8de Merge branch 'main' into reattach-pdf 2024-02-09 20:51:15 +05:30
5391dd91b0 Merge branch 'main' into reattach-pdf 2024-02-08 19:24:09 +05:30
4855882ae6 Update label render condition 2024-02-07 21:31:51 +05:30
c08768a330 Format code with prettier 2024-02-06 21:01:48 +05:30
37e9db6626 Remove document on go back click on step 1
Invoke onBackStep on "go back" click and conditionally render go back label
2024-02-06 00:40:53 +05:30
174 changed files with 9979 additions and 58125 deletions

View File

@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
# [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
@ -75,7 +79,7 @@ NEXT_PRIVATE_SMTP_APIKEY=
# OPTIONAL: Defines whether to force the use of TLS.
NEXT_PRIVATE_SMTP_SECURE=
# REQUIRED: Defines the sender name to use for the from address.
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
# REQUIRED: Defines the email address to use as the from address.
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
# OPTIONAL: The API key to use for Resend.com

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@ -33,9 +33,9 @@ jobs:
- uses: ./.github/actions/cache-build
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

View File

@ -33,7 +33,7 @@ jobs:
- name: Run Playwright tests
run: npm run ci
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results

View File

@ -27,7 +27,7 @@ jobs:
- name: Check Assigned User's Issue Count
id: parse-comment
uses: actions/github-script@v5
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

25
.github/workflows/issue-labeler.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Auto Label Assigned Issues
on:
issues:
types: [assigned]
jobs:
label-when-assigned:
runs-on: ubuntu-latest
steps:
- name: Label issue
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const issue = context.issue;
// To run only on issues and not on PR
if (github.context.payload.issue.pull_request === undefined) {
const labelResponse = await github.rest.issues.addLabels({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
labels: ['status: assigned']
});
}

View File

@ -17,5 +17,5 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["needs triage"]
labels: ["status: triage"]
})

View File

@ -2,14 +2,14 @@ name: 'PR Review Reminder'
on:
pull_request:
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
types: ['opened', 'ready_for_review']
permissions:
pull-requests: write
jobs:
checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@v4
- uses: actions/stale@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 90

View File

@ -6,7 +6,7 @@ tasks:
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)"
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
command: npm run d
ports:
@ -25,20 +25,10 @@ ports:
- port: 2500
visibility: private
onOpen: ignore
- port: 54320
visibility: private
- port: 54320
visibility: private
onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
pullRequestsFromForks: true
addCheck: true
addComment: true
addBadge: true
vscode:
extensions:
- aaron-bond.better-comments
@ -47,9 +37,5 @@ vscode:
- esbenp.prettier-vscode
- mikestead.dotenv
- unifiedjs.vscode-mdx
- GitHub.copilot-chat
- GitHub.copilot-labs
- GitHub.copilot
- GitHub.vscode-pull-request-github
- Prisma.prisma
- VisualStudioExptTeam.vscodeintellicode

View File

@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
experimental: {
@ -38,6 +42,7 @@ const config = {
env: {
NEXT_PUBLIC_PROJECT: 'marketing',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {

File diff suppressed because one or more lines are too long

View File

@ -3,11 +3,11 @@
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import type { GetCompletedDocumentsMonthlyResult } from '@documenso/lib/server-only/user/get-monthly-completed-document';
export type MonthlyCompletedDocumentsChartProps = {
className?: string;
data: GetUserMonthlyGrowthResult;
data: GetCompletedDocumentsMonthlyResult;
};
export const MonthlyCompletedDocumentsChart = ({

View File

@ -3,11 +3,11 @@
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import type { GetCompletedDocumentsMonthlyResult } from '@documenso/lib/server-only/user/get-monthly-completed-document';
export type TotalSignedDocumentsChartProps = {
className?: string;
data: GetUserMonthlyGrowthResult;
data: GetCompletedDocumentsMonthlyResult;
};
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
}
try {
const putFileData = await putFile(uploadedFile.file);
const putFileData = await putPdfFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {
@ -248,6 +248,7 @@ export const SinglePlayerClient = () => {
recipients={uploadedFile ? [placeholderRecipient] : []}
fields={fields}
onSubmit={onFieldsSubmit}
canGoBack={true}
isDocumentPdfLoaded={true}
/>
</fieldset>

View File

@ -70,7 +70,7 @@ export const ShareConnectPaidWidgetBento = ({
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed">
<strong className="block">Get paid (Soon).</strong>
Integrated payments with stripe so you dont have to worry about getting paid.
Integrated payments with Stripe so you dont have to worry about getting paid.
</p>
<div className="flex items-center justify-center p-8">

View File

@ -13,6 +13,7 @@ import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentSource,
DocumentStatus,
FieldType,
ReadStatus,
@ -104,6 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const document = await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,

View File

@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
@ -42,6 +46,7 @@ const config = {
APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {

View File

@ -28,6 +28,7 @@
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"input-otp": "^1.2.4",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",

View File

@ -12,5 +12,9 @@ declare namespace NodeJS {
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
}
}

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,8 @@
import Link from 'next/link';
import { type Document, DocumentStatus } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import { type Document, SigningStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -17,9 +18,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminActionsProps = {
className?: string;
document: Document;
recipients: Recipient[];
};
export const AdminActions = ({ className, document }: AdminActionsProps) => {
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
@ -47,7 +49,9 @@ export const AdminActions = ({ className, document }: AdminActionsProps) => {
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.status !== DocumentStatus.COMPLETED}
disabled={recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
)}
onClick={() => resealDocument({ id: document.id })}
>
Reseal document

View File

@ -53,7 +53,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
<h2 className="text-lg font-semibold">Admin Actions</h2>
<AdminActions className="mt-2" document={document} />
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
<hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2>

View File

@ -332,6 +332,7 @@ export const EditDocumentForm = ({
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit}
/>
<AddSignersFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}

View File

@ -36,11 +36,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const { user } = await getRequiredServerComponentSession();
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
const document = await getDocumentWithDetailsById({
id: documentId,
userId: user.id,
@ -74,6 +69,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
documentMeta.password = securePassword;
}
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">

View File

@ -3,6 +3,7 @@
import { useTransition } from 'react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -62,7 +63,12 @@ export const DocumentsDataTable = ({
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
cell: ({ row }) => (
<LocaleDate
date={row.original.createdAt}
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
/>
),
},
{
header: 'Title',

View File

@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
try {
setIsLoading(true);
const { type, data } = await putFile(file);
const { type, data } = await putPdfFile(file);
const { id: documentDataId } = await createDocumentData({
type,
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
});
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) {
console.error(error);
} catch (err) {
const error = AppError.parseError(err);
if (error instanceof TRPCClientError) {
console.error(err);
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({
title: 'Error',
description: error.message,
description: err.message,
variant: 'destructive',
});
} else {

View File

@ -1,10 +1,14 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -19,52 +23,135 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type EditTemplateFormProps = {
className?: string;
user: User;
template: Template;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
initialTemplate: TemplateWithDetails;
isEnterprise: boolean;
templateRootPath: string;
};
type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
type EditTemplateStep = 'settings' | 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const EditTemplateForm = ({
initialTemplate,
className,
template,
recipients,
fields,
user: _user,
documentData,
isEnterprise,
templateRootPath,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('signers');
const team = useOptionalCurrentTeam();
const [step, setStep] = useState<EditTemplateStep>('settings');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const utils = trpc.useUtils();
const { data: template, refetch: refetchTemplate } =
trpc.template.getTemplateWithDetailsById.useQuery(
{
id: initialTemplate.id,
},
{
initialData: initialTemplate,
...SKIP_QUERY_BATCH_META,
},
);
const { Recipient: recipients, Field: fields, templateDocumentData } = template;
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the template.',
stepIndex: 1,
},
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1,
stepIndex: 2,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
stepIndex: 3,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
templateId: template.id,
teamId: team?.id,
data: {
title: data.title,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: data.meta,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the document settings.',
variant: 'destructive',
});
}
};
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
@ -72,9 +159,11 @@ export const EditTemplateForm = ({
try {
await addTemplateSigners({
templateId: template.id,
teamId: team?.id,
signers: data.signers,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('fields');
@ -100,6 +189,9 @@ export const EditTemplateForm = ({
duration: 5000,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
router.push(templateRootPath);
} catch (err) {
toast({
@ -110,6 +202,15 @@ export const EditTemplateForm = ({
}
};
/**
* Refresh the data in the background when steps change.
*/
useEffect(() => {
void refetchTemplate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
@ -117,7 +218,11 @@ export const EditTemplateForm = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
<LazyPDFViewer
key={templateDocumentData.id}
documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
@ -135,12 +240,26 @@ export const EditTemplateForm = ({
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplateFieldsFormPartial

View File

@ -0,0 +1,40 @@
'use client';
import React, { useState } from 'react';
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';
export type TemplatePageViewProps = {
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
};
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
return (
<div>
<Button
variant="outline"
className="px-3"
onClick={(e) => {
e.preventDefault();
setTemplateDirectLinkOpen(true);
}}
>
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
{template.directLink ? 'Manage' : 'Create'} Direct Link
</Button>
<TemplateDirectLinkDialog
template={template}
open={isTemplateDirectLinkOpen}
onOpenChange={setTemplateDirectLinkOpen}
/>
</div>
);
};

View File

@ -5,16 +5,17 @@ import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { TemplateType } from '~/components/formatter/template-type';
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
import { EditTemplateForm } from './edit-template';
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
export type TemplatePageViewProps = {
params: {
@ -35,7 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
const template = await getTemplateWithDetailsById({
id: templateId,
userId: user.id,
}).catch(() => null);
@ -44,42 +45,47 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
redirect(templateRootPath);
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
const isTemplateEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
</div>
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/>
</div>
);

View File

@ -4,10 +4,10 @@ import { useState } from 'react';
import Link from 'next/link';
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import type { Template } from '@documenso/prisma/client';
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import {
DropdownMenu,
DropdownMenuContent,
@ -18,9 +18,10 @@ import {
import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
export type DataTableActionDropdownProps = {
row: Template;
row: FindTemplateRow;
templateRootPath: string;
teamId?: number;
};
@ -33,6 +34,7 @@ export const DataTableActionDropdown = ({
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) {
@ -66,6 +68,11 @@ export const DataTableActionDropdown = ({
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
<Share2Icon className="mr-2 h-4 w-4" />
Direct link
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
@ -82,6 +89,12 @@ export const DataTableActionDropdown = ({
onOpenChange={setDuplicateDialogOpen}
/>
<TemplateDirectLinkDialog
template={row}
open={isTemplateDirectLinkDialogOpen}
onOpenChange={setTemplateDirectLinkDialogOpen}
/>
<DeleteTemplateDialog
id={row.id}
open={isDeleteDialogOpen}

View File

@ -4,32 +4,26 @@ import { useTransition } from 'react';
import Link from 'next/link';
import { AlertTriangle, Loader } from 'lucide-react';
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Recipient, Template } from '@documenso/prisma/client';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { LocaleDate } from '~/components/formatter/locale-date';
import { TemplateType } from '~/components/formatter/template-type';
import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
import { TemplateDirectLinkBadge } from './template-direct-link-badge';
import { UseTemplateDialog } from './use-template-dialog';
type TemplateWithRecipient = Template & {
Recipient: Recipient[];
};
type TemplatesDataTableProps = {
templates: Array<
TemplateWithRecipient & {
team: { id: number; url: string } | null;
}
>;
templates: FindTemplateRow[];
perPage: number;
page: number;
totalPages: number;
@ -48,6 +42,7 @@ export const TemplatesDataTable = ({
teamId,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const { remaining } = useLimits();
@ -88,9 +83,70 @@ export const TemplatesDataTable = ({
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
header: 'Type',
header: () => (
<div className="flex flex-row items-center">
Type
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
Public
</h2>
<p>
Public templates are connected to your public profile. Any modifications
to public templates will also appear in your public profile.
</p>
</li>
<li>
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
<Link2Icon className="mr-1 h-3 w-3" />
direct link
</div>
<p>
Direct link templates contain one dynamic recipient placeholder. Anyone
with access to this link can sign the document, and it will then appear on
your documents page.
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
{teamId ? 'Team Only' : 'Private'}
</h2>
<p>
{teamId
? 'Team only templates are not linked anywhere and are visible only to your team.'
: 'Private templates can only be modified and viewed by you.'}
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => <TemplateType type={row.original.type} />,
cell: ({ row }) => (
<div className="flex flex-row items-center">
<TemplateType type="PRIVATE" />
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
),
},
{
header: 'Actions',

View File

@ -1,21 +1,16 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { FilePlus, X } from 'lucide-react';
import { FilePlus, Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogClose,
@ -27,24 +22,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
type NewTemplateDialogProps = {
teamId?: number;
templateRootPath: string;
@ -56,50 +35,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { data: session } = useSession();
const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
defaultValues: {
name: '',
},
resolver: zodResolver(ZCreateTemplateFormSchema),
});
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const [isUploadingFile, setIsUploadingFile] = useState(false);
const onFileDrop = async (file: File) => {
try {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
if (!form.getValues('name')) {
form.setValue('name', file.name);
}
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const onSubmit = async (values: TCreateTemplateFormSchema) => {
if (!uploadedFile) {
if (isUploadingFile) {
return;
}
const file: File = uploadedFile.file;
setIsUploadingFile(true);
try {
const { type, data } = await putFile(file);
const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,
data,
@ -107,7 +56,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { id } = await createTemplate({
teamId,
title: values.name ? values.name : file.name,
title: file.name,
templateDocumentDataId,
});
@ -127,26 +76,16 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
description: 'Please try again later.',
variant: 'destructive',
});
setIsUploadingFile(false);
}
};
const resetForm = () => {
if (form.getValues('name') === uploadedFile?.file.name) {
form.reset();
}
setUploadedFile(null);
};
useEffect(() => {
if (!showNewTemplateDialog) {
form.reset();
setUploadedFile(null);
}
}, [form, showNewTemplateDialog]);
return (
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
<Dialog
open={showNewTemplateDialog}
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
@ -162,80 +101,23 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Template name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
<span className="text-muted-foreground text-xs">
Leave this empty if you would like to use your document's name for the
template
</span>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="relative">
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
<div className="mt-1.5">
{uploadedFile ? (
<Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2">
<button
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
{isUploadingFile && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
</div>
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</div>
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
Uploaded Document
</p>
<span className="text-muted-foreground/80 mt-1 text-sm">
{uploadedFile.file.name}
</span>
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button
loading={form.formState.isSubmitting}
disabled={!uploadedFile}
type="submit"
>
Create template
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isUploadingFile}>
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@ -0,0 +1,45 @@
'use client';
import { Link2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkBadgeProps = {
token: string;
enabled: boolean;
className?: string;
};
export const TemplateDirectLinkBadge = ({
token,
enabled,
className,
}: TemplateDirectLinkBadgeProps) => {
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
});
});
return (
<button
title="Copy direct link"
className={cn(
'flex flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600',
className,
)}
onClick={async () => onCopyClick(token)}
>
<Link2Icon className="mr-1 h-3 w-3" />
direct link {!enabled && 'disabled'}
</button>
);
};

View File

@ -0,0 +1,448 @@
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import {
DIRECT_TEMPLATE_DOCUMENTATION,
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
} 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';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Switch } from '@documenso/ui/primitives/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkDialogProps = {
template: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
Recipient: Recipient[];
};
open: boolean;
onOpenChange: (_open: boolean) => void;
};
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
export const TemplateDirectLinkDialog = ({
template,
open,
onOpenChange,
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
const [, copy] = useCopyToClipboard();
const router = useRouter();
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
const [token, setToken] = useState(template.directLink?.token ?? null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
token ? 'MANAGE' : 'ONBOARD',
);
const validDirectTemplateRecipients = useMemo(
() => template.Recipient.filter((recipient) => recipient.role !== RecipientRole.CC),
[template.Recipient],
);
const {
mutateAsync: createTemplateDirectLink,
isLoading: isCreatingTemplateDirectLink,
reset: resetCreateTemplateDirectLink,
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: (data) => {
setToken(data.token);
setIsEnabled(data.enabled);
setCurrentStep('MANAGE');
router.refresh();
},
onError: () => {
setSelectedRecipientId(null);
toast({
title: 'Something went wrong',
description: 'Unable to create direct template access. Please try again later.',
variant: 'destructive',
});
},
});
const { mutateAsync: toggleTemplateDirectLink, isLoading: isTogglingTemplateAccess } =
trpcReact.template.toggleTemplateDirectLink.useMutation({
onSuccess: (data) => {
toast({
title: 'Success',
description: `Direct link signing has been ${data.enabled ? 'enabled' : 'disabled'}`,
});
},
onError: (_ctx, data) => {
toast({
title: 'Something went wrong',
description: `An error occurred while ${
data.enabled ? 'enabling' : 'disabling'
} direct link signing.`,
variant: 'destructive',
});
},
});
const { mutateAsync: deleteTemplateDirectLink, isLoading: isDeletingTemplateDirectLink } =
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: () => {
onOpenChange(false);
setToken(null);
toast({
title: 'Success',
description: 'Direct template link deleted',
duration: 5000,
});
router.refresh();
setToken(null);
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We encountered an error while removing the direct template link. Please try again later.',
variant: 'destructive',
});
},
});
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
});
});
const onRecipientTableRowClick = async (recipientId: number) => {
if (isLoading) {
return;
}
setSelectedRecipientId(recipientId);
await createTemplateDirectLink({
templateId: template.id,
directRecipientId: recipientId,
});
};
const isLoading =
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
useEffect(() => {
resetCreateTemplateDirectLink();
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
setSelectedRecipientId(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<fieldset disabled={isLoading} className="relative">
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ token, currentStep })
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>Create Direct Signing Link</DialogTitle>
<DialogDescription>Here's how it works:</DialogDescription>
</DialogHeader>
<ul className="mt-4 space-y-4 pl-12">
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
<li className="relative" key={index}>
<div className="absolute -left-12">
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
{index + 1}
</div>
</div>
<h3 className="font-semibold">{step.title}</h3>
<p className="text-muted-foreground mt-1 text-sm">{step.description}</p>
</li>
))}
</ul>
{remaining.directTemplates === 0 && (
<Alert variant="warning">
<AlertTitle>
Direct template link usage exceeded ({quota.directTemplates}/
{quota.directTemplates})
</AlertTitle>
<AlertDescription>
You have reached the maximum limit of {quota.directTemplates} direct
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
href="/settings/billing"
>
Upgrade your account to continue!
</Link>
</AlertDescription>
</Alert>
)}
{remaining.directTemplates !== 0 && (
<DialogFooter className="mx-auto mt-4">
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
Enable direct link signing
</Button>
</DialogFooter>
)}
</DialogContent>
))
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
<DialogContent className="relative">
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
</div>
)}
<DialogHeader>
<DialogTitle>Choose Direct Link Recipient</DialogTitle>
<DialogDescription>
Choose an existing recipient from below to continue
</DialogDescription>
</DialogHeader>
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Recipient</TableHead>
<TableHead>Role</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{validDirectTemplateRecipients.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="h-16 text-center">
<p className="text-muted-foreground">No valid recipients found</p>
</TableCell>
</TableRow>
)}
{validDirectTemplateRecipients.map((row) => (
<TableRow
className="cursor-pointer"
key={row.id}
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-muted-foreground text-sm">
<p>{row.name}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{RECIPIENT_ROLES_DESCRIPTION[row.role].roleName}
</TableCell>
<TableCell>
{selectedRecipientId === row.id ? (
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
) : (
<CircleIcon className="h-5 w-5 text-neutral-300" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
{!template.Recipient.some(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
) && (
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">Or</p>
)}
<Button
type="button"
className="mt-2"
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
onClick={async () =>
createTemplateDirectLink({
templateId: template.id,
})
}
>
Create one automatically
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
))
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Direct Link Signing</DialogTitle>
<DialogDescription>
Manage the direct link signing for this template
</DialogDescription>
</DialogHeader>
<div>
<div className="flex flex-row items-center justify-between">
<Label className="flex flex-row">
Enable Direct Link Signing
<Tooltip>
<TooltipTrigger tabIndex={-1} className="ml-2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
Disabling direct link signing will prevent anyone from accessing the link.
</TooltipContent>
</Tooltip>
</Label>
<Switch
className="mt-2"
checked={isEnabled}
onCheckedChange={(value) => setIsEnabled(value)}
/>
</div>
<div className="mt-2">
<Label htmlFor="copy-direct-link">Copy Shareable Link</Label>
<div className="relative mt-1">
<Input
id="copy-direct-link"
disabled
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
readOnly
className="pr-12"
/>
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
<Button
variant="none"
type="button"
className="h-8 w-8"
onClick={() => void onCopyClick(token)}
>
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
</Button>
</div>
</div>
</div>
</div>
<DialogFooter className='mt-4'>
<Button
type="button"
variant="destructive"
className="mr-auto w-full sm:w-auto"
loading={isDeletingTemplateDirectLink}
onClick={() => setCurrentStep('CONFIRM_DELETE')}
>
Remove
</Button>
<Button
type="button"
loading={isTogglingTemplateAccess}
onClick={async () =>
toggleTemplateDirectLink({
templateId: template.id,
enabled: isEnabled,
})
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
))
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Please note that proceeding will remove direct linking recipient and turn it
into a placeholder.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setCurrentStep('MANAGE')}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
loading={isDeletingTemplateDirectLink}
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
))
.otherwise(() => null)}
</AnimateGenericFadeInOut>
</fieldset>
</Dialog>
);
};

View File

@ -1,14 +1,21 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { InfoIcon, Plus } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@ -19,24 +26,59 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
const ZAddRecipientsForNewDocumentSchema = z
.object({
sendDocument: z.boolean(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -54,35 +96,33 @@ export function UseTemplateDialog({
const router = useRouter();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const team = useOptionalCurrentTeam();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
recipients:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}))
: [
{
name: '',
email: '',
role: RecipientRole.SIGNER,
},
],
sendDocument: false,
recipients: recipients.map((recipient) => {
const isRecipientEmailPlaceholder = recipient.email.match(
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
);
const isRecipientNamePlaceholder = recipient.name.match(
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
);
return {
id: recipient.id,
name: !isRecipientNamePlaceholder ? recipient.name : '',
email: !isRecipientEmailPlaceholder ? recipient.email : '',
};
}),
},
});
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
@ -91,6 +131,7 @@ export function UseTemplateDialog({
templateId,
teamId: team?.id,
recipients: data.recipients,
sendDocument: data.sendDocument,
});
toast({
@ -101,146 +142,147 @@ export function UseTemplateDialog({
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
const error = AppError.parseError(err);
const toastPayload: Toast = {
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
};
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = 'The document was created but could not be sent to recipients.';
}
toast(toastPayload);
}
};
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({
control,
control: form.control,
name: 'recipients',
});
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog>
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
<Button className="cursor-pointer">
<Button variant="outline" className="bg-background">
<Plus className="-ml-1 mr-2 h-4 w-4" />
Use Template
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Document Recipients</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
<DialogTitle>Create document from template</DialogTitle>
<DialogDescription>
{recipients.length === 0
? 'A draft document will be created'
: 'Add the recipients to create the document with'}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller
control={control}
name={`recipients.${index}.email`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].email || 'Email'} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel>Name</FormLabel>}
<Controller
control={control}
name={`recipients.${index}.name`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
<FormControl>
<Input {...field} placeholder={recipients[index].name || 'Name'} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</div>
))}
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`recipients.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
{recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="sendDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="sendDocument"
>
Send document
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
The document will be immediately sent to recipients if this is
checked.
</p>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<p>Otherwise, the document will be created as a draft.</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
</div>
)}
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
</div>
</div>
))}
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button>
</DialogFooter>
<Button type="submit" loading={form.formState.isSubmitting}>
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -0,0 +1,158 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { Field, Recipient } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useStep } from '@documenso/ui/primitives/stepper';
import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider';
const ZConfigureDirectTemplateFormSchema = z.object({
email: z.string().email('Email is invalid'),
});
export type TConfigureDirectTemplateFormSchema = z.infer<typeof ZConfigureDirectTemplateFormSchema>;
export type ConfigureDirectTemplateFormProps = {
flowStep: DocumentFlowStep;
isDocumentPdfLoaded: boolean;
template: TemplateWithDetails;
directTemplateRecipient: Recipient & { Field: Field[] };
initialEmail?: string;
onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void;
};
export const ConfigureDirectTemplateFormPartial = ({
flowStep,
isDocumentPdfLoaded,
template,
directTemplateRecipient,
initialEmail,
onSubmit,
}: ConfigureDirectTemplateFormProps) => {
const { Recipient } = template;
const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext();
const { data: session } = useSession();
const recipientsWithBlankDirectRecipientEmail = Recipient.map((recipient) => {
if (recipient.id === directTemplateRecipient.id) {
return {
...recipient,
email: '',
};
}
return recipient;
});
const form = useForm<TConfigureDirectTemplateFormSchema>({
resolver: zodResolver(
ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => {
if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Email cannot already exist in the template',
path: ['email'],
});
}
}),
),
defaultValues: {
email: initialEmail || '',
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
return (
<>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
directTemplateRecipient.Field.map((field, index) => (
<ShowFieldItem
key={index}
field={field}
recipients={recipientsWithBlankDirectRecipientEmail}
/>
))}
<Form {...form}>
<fieldset
className="flex h-full flex-col space-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="email"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input
{...field}
disabled={
field.disabled ||
derivedRecipientAccessAuth !== null ||
session?.user.email !== undefined
}
placeholder="recipient@documenso.com"
/>
</FormControl>
{!fieldState.error && (
<p className="text-muted-foreground text-xs">
Enter your email address to receive the completed document.
</p>
)}
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</Form>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={flowStep.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -0,0 +1,159 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { Field } from '@documenso/prisma/client';
import { type Recipient } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import type { TConfigureDirectTemplateFormSchema } from './configure-direct-template';
import { ConfigureDirectTemplateFormPartial } from './configure-direct-template';
import type { DirectTemplateLocalField } from './sign-direct-template';
import { SignDirectTemplateForm } from './sign-direct-template';
export type TemplatesDirectPageViewProps = {
template: TemplateWithDetails;
directTemplateToken: string;
directTemplateRecipient: Recipient & { Field: Field[] };
};
type DirectTemplateStep = 'configure' | 'sign';
const DirectTemplateSteps: DirectTemplateStep[] = ['configure', 'sign'];
export const DirectTemplatePageView = ({
template,
directTemplateRecipient,
directTemplateToken,
}: TemplatesDirectPageViewProps) => {
const router = useRouter();
const { toast } = useToast();
const { email, setEmail } = useRequiredSigningContext();
const { recipient, setRecipient } = useRequiredDocumentAuthContext();
const [step, setStep] = useState<DirectTemplateStep>('configure');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role];
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
configure: {
title: 'General',
description: 'Preview and configure template.',
stepIndex: 1,
},
sign: {
title: `${recipientRoleDescription.actionVerb} document`,
description: `${recipientRoleDescription.actionVerb} the document to complete the process.`,
stepIndex: 2,
},
};
const { mutateAsync: createDocumentFromDirectTemplate } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
/**
* Set the email into a temporary recipient so it can be used for reauth and signing email fields.
*/
const onConfigureDirectTemplateSubmit = ({ email }: TConfigureDirectTemplateFormSchema) => {
setEmail(email);
setRecipient({
...recipient,
email,
});
setStep('sign');
};
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
try {
const token = await createDocumentFromDirectTemplate({
directTemplateToken,
directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt,
signedFieldValues: fields.map((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
return field.signedValue;
}),
});
const redirectUrl = template.templateMeta?.redirectUrl;
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${token}/complete`);
} catch (err) {
toast({
title: 'Something went wrong',
description: 'We were unable to submit this document at this time. Please try again later.',
variant: 'destructive',
});
throw err;
}
};
const currentDocumentFlow = directTemplateFlow[step];
return (
<div className="grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={template.id}
documentData={template.templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(DirectTemplateSteps[step - 1])}
>
<ConfigureDirectTemplateFormPartial
flowStep={directTemplateFlow.configure}
template={template}
directTemplateRecipient={directTemplateRecipient}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onConfigureDirectTemplateSubmit}
initialEmail={email}
/>
<SignDirectTemplateForm
flowStep={directTemplateFlow.sign}
directRecipient={recipient}
directRecipientFields={directTemplateRecipient.Field}
template={template}
onSubmit={onSignDirectTemplateSubmit}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

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

View File

@ -0,0 +1,92 @@
import { notFound, redirect } from 'next/navigation';
import { UsersIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { truncateTitle } from '~/helpers/truncate-title';
import { DirectTemplatePageView } from './direct-template';
import { DirectTemplateAuthPageView } from './signing-auth-page';
export type TemplatesDirectPageProps = {
params: {
token: string;
};
};
export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) {
const { token } = params;
if (!token) {
redirect('/');
}
const { user } = await getServerComponentSession();
const template = await getTemplateByDirectLinkToken({
token,
}).catch(() => null);
if (!template || !template.directLink?.enabled) {
notFound();
}
const directTemplateRecipient = template.Recipient.find(
(recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
);
if (!directTemplateRecipient) {
notFound();
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
.with(null, () => true)
.exhaustive();
if (!isAccessAuthValid) {
return <DirectTemplateAuthPageView />;
}
return (
<SigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
<DocumentAuthProvider
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
user={user}
>
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{truncateTitle(template.title)}
</h1>
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
<UsersIcon className="h-4 w-4" />
<p className="text-muted-foreground/80">
{template.Recipient.length}{' '}
{template.Recipient.length > 1 ? 'recipients' : 'recipient'}
</p>
</div>
<DirectTemplatePageView
directTemplateRecipient={directTemplateRecipient}
directTemplateToken={template.directLink.token}
template={template}
/>
</div>
</DocumentAuthProvider>
</SigningProvider>
);
}

View File

@ -0,0 +1,278 @@
import { useMemo, useState } from 'react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useStep } from '@documenso/ui/primitives/stepper';
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
export type SignDirectTemplateFormProps = {
flowStep: DocumentFlowStep;
directRecipient: Recipient;
directRecipientFields: Field[];
template: TemplateWithDetails;
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
};
export type DirectTemplateLocalField = Field & {
signedValue?: TSignFieldWithTokenMutationSchema;
Signature?: Signature;
};
export const SignDirectTemplateForm = ({
flowStep,
directRecipient,
directRecipientFields,
template,
onSubmit,
}: SignDirectTemplateFormProps) => {
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { currentStep, totalSteps, previousStep } = useStep();
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
setLocalFields(
localFields.map((field) => {
if (field.id !== value.fieldId) {
return field;
}
const tempField: DirectTemplateLocalField = {
...field,
customText: value.value,
inserted: true,
signedValue: value,
};
if (field.type === FieldType.SIGNATURE) {
tempField.Signature = {
id: 1,
created: new Date(),
recipientId: 1,
fieldId: 1,
signatureImageAsBase64: value.value,
typedSignature: null,
};
}
if (field.type === FieldType.DATE) {
tempField.customText = DateTime.now()
.setZone(template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
}
return tempField;
}),
);
};
const onUnsignField = (value: TRemovedSignedFieldWithTokenMutationSchema) => {
setLocalFields(
localFields.map((field) => {
if (field.id !== value.fieldId) {
return field;
}
return {
...field,
customText: '',
inserted: false,
signedValue: undefined,
Signature: undefined,
};
}),
);
};
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
}, [localFields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(localFields);
};
const handleSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(localFields);
if (!isFieldsValid) {
return;
}
setIsSubmitting(true);
try {
await onSubmit(localFields);
} catch {
setIsSubmitting(false);
}
// Do not reset to false since we do a redirect.
};
return (
<>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
{localFields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.NAME, () => (
<NameField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={directRecipient}
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.TEXT, () => (
<TextField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.otherwise(() => null),
)}
</ElementVisible>
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
id="full-name"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={flowStep.title}
step={currentStep}
maxStep={totalSteps}
/>
<div className="mt-4 flex gap-x-4">
<Button
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
disabled={isSubmitting}
onClick={previousStep}
>
Back
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit}
documentTitle={template.title}
fields={localFields}
fieldsValidated={fieldsValidated}
role={directRecipient.role}
/>
</div>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import { signOut } from 'next-auth/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const DirectTemplateAuthPageView = () => {
const { toast } = useToast();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async () => {
try {
setIsSigningOut(true);
await signOut({
callbackUrl: '/signin',
});
} catch {
toast({
title: 'Something went wrong',
description: 'We were unable to log you out at this time.',
duration: 10000,
variant: 'destructive',
});
}
setIsSigningOut(false);
};
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<h1 className="text-3xl font-semibold">Authentication required</h1>
<p className="text-muted-foreground mt-2 text-sm">
You need to be logged in to view this page.
</p>
<Button
className="mt-4 w-full"
type="submit"
onClick={async () => handleChangeAccount()}
loading={isSigningOut}
>
Login
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';
type RecipientLayoutProps = {
children: React.ReactNode;
};
/**
* A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not.
*
* Such as direct template access, or signing.
*/
export default async function RecipientLayout({ children }: RecipientLayoutProps) {
const { user, session } = await getServerComponentSession();
let teams: GetTeamsResponse = [];
if (user && session) {
teams = await getTeams({ userId: user.id });
}
return (
<NextAuthProvider session={session}>
<div className="min-h-screen">
{user && <AuthenticatedHeader user={user} teams={teams} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
</div>
</NextAuthProvider>
);
}

View File

@ -67,7 +67,7 @@ export default async function CompletedSigningPage({
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document,
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
@ -131,7 +131,7 @@ export default async function CompletedSigningPage({
</div>
))
.with({ deletedAt: null }, () => (
<div className="flex items-center mt-4 text-center text-blue-600">
<div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>

View File

@ -17,6 +17,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningFieldContainer } from './signing-field-container';
@ -26,6 +30,8 @@ export type DateFieldProps = {
recipient: Recipient;
dateFormat?: string | null;
timezone?: string | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DateField = ({
@ -33,6 +39,8 @@ export const DateField = ({
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
onSignField,
onUnsignField,
}: DateFieldProps) => {
const router = useRouter();
@ -58,12 +66,19 @@ export const DateField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
await signFieldWithToken({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
@ -85,10 +100,17 @@ export const DateField = ({
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -18,7 +18,7 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
<FormLabel required>2FA token</FormLabel>
<FormControl>
<Input {...field} placeholder="Token" />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />

View File

@ -34,9 +34,9 @@ type PasskeyData = {
export type DocumentAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
document: Document;
documentAuthOptions: Document['authOptions'];
documentAuthOption: TDocumentAuthOptions;
setDocument: (_value: Document) => void;
setDocumentAuthOptions: (_value: Document['authOptions']) => void;
recipient: Recipient;
recipientAuthOption: TRecipientAuthOptions;
setRecipient: (_value: Recipient) => void;
@ -69,19 +69,19 @@ export const useRequiredDocumentAuthContext = () => {
};
export interface DocumentAuthProviderProps {
document: Document;
documentAuthOptions: Document['authOptions'];
recipient: Recipient;
user?: User | null;
children: React.ReactNode;
}
export const DocumentAuthProvider = ({
document: initialDocument,
documentAuthOptions: initialDocumentAuthOptions,
recipient: initialRecipient,
user,
children,
}: DocumentAuthProviderProps) => {
const [document, setDocument] = useState(initialDocument);
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
const [recipient, setRecipient] = useState(initialRecipient);
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
@ -95,10 +95,10 @@ export const DocumentAuthProvider = ({
} = useMemo(
() =>
extractDocumentAuthMethods({
documentAuth: document.authOptions,
documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions,
}),
[document, recipient],
[documentAuthOptions, recipient],
);
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
@ -189,8 +189,8 @@ export const DocumentAuthProvider = ({
<DocumentAuthContext.Provider
value={{
user,
document,
setDocument,
documentAuthOptions,
setDocumentAuthOptions,
executeActionAuthProcedure,
recipient,
setRecipient,

View File

@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
@ -20,9 +24,11 @@ import { SigningFieldContainer } from './signing-field-container';
export type EmailFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const EmailField = ({ field, recipient }: EmailFieldProps) => {
export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -43,13 +49,22 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
await signFieldWithToken({
const value = providedEmail ?? '';
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: providedEmail ?? '',
value,
isBase64: false,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
@ -71,10 +86,17 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -145,7 +145,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
@ -208,7 +208,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}

View File

@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@ -25,9 +29,11 @@ import { SigningFieldContainer } from './signing-field-container';
export type NameFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const NameField = ({ field, recipient }: NameFieldProps) => {
export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -83,13 +89,20 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
return;
}
await signFieldWithToken({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value,
isBase64: false,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
@ -111,10 +124,17 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -65,7 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document,
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
@ -126,7 +126,11 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
fullName={user?.email === recipient.email ? user.name : recipient.name}
signature={user?.email === recipient.email ? user.signature : undefined}
>
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
<DocumentAuthProvider
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
>
<SigningPageView
recipient={recipient}
document={document}

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import type { Document, Field } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -16,7 +16,7 @@ import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = {
isSubmitting: boolean;
document: Document;
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
@ -25,14 +25,14 @@ export type SignDialogProps = {
export const SignDialog = ({
isSubmitting,
document,
documentTitle,
fields,
fieldsValidated,
onSignatureComplete,
role,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
const truncatedTitle = truncateTitle(documentTitle);
const isComplete = fields.every((field) => field.inserted);
const handleOpenChange = (open: boolean) => {
@ -40,18 +40,6 @@ export const SignDialog = ({
return;
}
// Reauth is currently not required for signing the document.
// if (isAuthRedirectRequired) {
// await executeActionAuthProcedure({
// actionTarget: 'DOCUMENT',
// onReauthFormSubmit: () => {
// // Do nothing since the user should be redirected.
// },
// });
// return;
// }
setShowDialog(open);
};

View File

@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
@ -29,9 +33,16 @@ type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type SignatureFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
export const SignatureField = ({
field,
recipient,
onSignField,
onUnsignField,
}: SignatureFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -105,13 +116,20 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return;
}
await signFieldWithToken({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value,
isBase64: true,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
@ -133,10 +151,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@ -24,9 +28,11 @@ import { SigningFieldContainer } from './signing-field-container';
export type TextFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const TextField = ({ field, recipient }: TextFieldProps) => {
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -81,13 +87,20 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
return;
}
await signFieldWithToken({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: localText,
isBase64: true,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
setLocalCustomText('');
@ -111,10 +124,17 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -1,7 +1,10 @@
import { DateTime } from 'luxon';
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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { GetTeamTokensResponse } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
@ -23,7 +26,24 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
let tokens: GetTeamTokensResponse | null = null;
try {
tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
} catch (err) {
const error = AppError.parseError(err);
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
{match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => error.message)
.otherwise(() => 'Something went wrong.')}
</p>
</div>
);
}
return (
<div>

View File

@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose">
<article className="prose dark:prose-invert">
<h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2>

View File

@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignInForm } from '~/components/forms/signin';
@ -37,10 +37,13 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
<p className="text-muted-foreground mt-2 text-sm">
Welcome back, we are lucky to have you.
</p>
<hr className="-mx-6 my-4" />
<SignInForm initialEmail={email || undefined} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
<SignInForm
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
/>
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">

View File

@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpFormV2 } from '~/components/forms/v2/signup';
@ -37,6 +37,7 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
/>
);
}

View File

@ -3,6 +3,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { motion } from 'framer-motion';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { signOut } from 'next-auth/react';
@ -25,6 +26,8 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
const MotionLink = motion(Link);
export type MenuSwitcherProps = {
user: User;
teams: GetTeamsResponse;
@ -170,18 +173,43 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<Link href={formatRedirectUrlOnSwitch(team.url)}>
<MotionLink
initial="initial"
animate="initial"
whileHover="animate"
href={formatRedirectUrlOnSwitch(team.url)}
>
<AvatarWithText
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
secondaryText={formatSecondaryAvatarText(team)}
secondaryText={
<div className="relative">
<motion.span
className="overflow-hidden"
variants={{
initial: { opacity: 1, translateY: 0 },
animate: { opacity: 0, translateY: '100%' },
}}
>
{formatSecondaryAvatarText(team)}
</motion.span>
<motion.span
className="absolute inset-0"
variants={{
initial: { opacity: 0, translateY: '100%' },
animate: { opacity: 1, translateY: 0 },
}}
>{`/t/${team.url}`}</motion.span>
</div>
}
rightSideComponent={
isPathTeamUrl(team.url) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</Link>
</MotionLink>
</DropdownMenuItem>
))}
</div>

View File

@ -1,6 +1,6 @@
import type { HTMLAttributes } from 'react';
import { Globe, Lock } from 'lucide-react';
import { Globe2, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
@ -22,7 +22,7 @@ const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
},
PUBLIC: {
label: 'Public',
icon: Globe,
icon: Globe2,
color: 'text-green-500 dark:text-green-300',
},
};

View File

@ -28,7 +28,7 @@ import {
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisable2FAForm = z.object({
@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -30,7 +30,7 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -30,7 +30,7 @@ import {
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { RecoveryCodeList } from './recovery-code-list';
@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -10,6 +10,7 @@ import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/br
import { KeyRoundIcon } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
import { match } from 'ts-pattern';
import { z } from 'zod';
@ -38,6 +39,7 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
@ -68,9 +70,15 @@ export type SignInFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
};
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
export const SignInForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
}: SignInFormProps) => {
const { toast } = useToast();
const { getFlag } = useFeatureFlags();
@ -256,6 +264,19 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
}
};
const onSignInWithOIDCClick = async () => {
try {
await signIn('oidc', { callbackUrl: LOGIN_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you In. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form
@ -316,7 +337,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
{(isGoogleSSOEnabled || isPasskeyEnabled || isOIDCSSOEnabled) && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span>
@ -338,6 +359,20 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
</Button>
)}
{isOIDCSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
OIDC
</Button>
)}
{isPasskeyEnabled && (
<Button
type="button"
@ -372,9 +407,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Token</FormLabel>
<FormLabel>Token</FormLabel>
<FormControl>
<Input type="text" {...field} />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -52,9 +52,15 @@ export type SignUpFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
};
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
export const SignUpForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
}: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
@ -121,6 +127,19 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
}
};
const onSignUpWithOIDCClick = async () => {
try {
await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form
@ -221,6 +240,28 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
</Button>
</>
)}
{isOIDCSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with OIDC
</Button>
</>
)}
</form>
</Form>
);

View File

@ -10,6 +10,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod';
@ -73,12 +74,14 @@ export type SignUpFormV2Props = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
};
export const SignUpFormV2 = ({
className,
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
}: SignUpFormV2Props) => {
const { toast } = useToast();
const analytics = useAnalytics();
@ -179,6 +182,19 @@ export const SignUpFormV2 = ({
}
};
const onSignUpWithOIDCClick = async () => {
try {
await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
@ -255,7 +271,7 @@ export const SignUpFormV2 = ({
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[650px]',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
)}
disabled={isSubmitting}
>
@ -323,14 +339,18 @@ export const SignUpFormV2 = ({
)}
/>
{isGoogleSSOEnabled && (
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
</>
)}
{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
@ -345,6 +365,22 @@ export const SignUpFormV2 = ({
</>
)}
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
Sign Up with OIDC
</Button>
</>
)}
<p className="text-muted-foreground mt-4 text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-documenso-700 duration-200 hover:opacity-70">

View File

@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
error: '/signin',
},
events: {
signIn: async ({ user }) => {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
});
signIn: async ({ user: { id: userId } }) => {
const [user] = await Promise.all([
await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
}),
]);
// Create the Stripe customer and attach it to the user if it doesn't exist.
if (user.customerId === null && IS_BILLING_ENABLED()) {
await getStripeCustomerByUser(user).catch((err) => {
console.error(err);
});
}
},
signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;

View File

@ -41,7 +41,7 @@ volumes:
1. Run the following command to start the containers:
```
docker-compose --env-file ./.env -d up
docker-compose --env-file ./.env up -d
```
This will start the PostgreSQL database and the Documenso application containers.

View File

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

2391
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
"prisma:migrate-reset": "npm run with:env -- npm run prisma:migrate-reset -w @documenso/prisma",
"prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma",
"prisma:studio": "npm run with:env -- npm run prisma:studio -w @documenso/prisma",
"with:env": "dotenv -e .env -e .env.local --",
@ -60,4 +61,4 @@
"next": "14.0.3"
}
}
}
}

View File

@ -12,6 +12,8 @@ import {
ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema,
ZDownloadDocumentSuccessfulSchema,
ZGenerateDocumentFromTemplateMutationResponseSchema,
ZGenerateDocumentFromTemplateMutationSchema,
ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema,
@ -85,6 +87,24 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new document from an existing template',
deprecated: true,
description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`,
},
generateDocumentFromTemplate: {
method: 'POST',
path: '/api/v1/templates/:templateId/generate-document',
body: ZGenerateDocumentFromTemplateMutationSchema,
responses: {
200: ZGenerateDocumentFromTemplateMutationResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new document from an existing template',
description:
'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.',
},
sendDocument: {

View File

@ -1,6 +1,8 @@
import { createNextRoute } from '@ts-rest/next';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
@ -19,10 +21,12 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import {
getPresignGetUrl,
getPresignPostUrl,
@ -73,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
status: 200,
body: {
...document,
recipients,
recipients: recipients.map((recipient) => ({
...recipient,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
} catch (err) {
@ -229,6 +236,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
requestMetadata: extractNextApiRequestMetadata(args.req),
});
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({
userId: user.id,
teamId: team?.id,
@ -248,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
@ -279,7 +295,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplate({
const document = await createDocumentFromTemplateLegacy({
templateId,
userId: user.id,
teamId: team?.id,
@ -296,7 +312,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@ -324,10 +340,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}
@ -342,6 +355,89 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
}),
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
};
}
const templateId = Number(params.templateId);
let document: CreateDocumentFromTemplateResponse | null = null;
try {
document = await createDocumentFromTemplate({
templateId,
userId: user.id,
teamId: team?.id,
recipients: body.recipients,
override: {
title: body.title,
...body.meta,
},
});
} catch (err) {
return AppError.toRestAPIError(err);
}
if (body.formValues) {
const fileName = document.title.endsWith('.pdf') ? document.title : `${document.title}.pdf`;
const pdf = await getFile(document.documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putPdfFile({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
await updateDocument({
documentId: document.id,
userId: user.id,
teamId: team?.id,
data: {
formValues: body.formValues,
documentData: {
connect: {
id: newDocumentData.id,
},
},
},
});
}
return {
status: 200,
body: {
documentId: document.id,
recipients: document.Recipient.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
@ -349,6 +445,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
sendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id } = args.params;
const { sendEmail = true } = args.body ?? {};
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
@ -404,10 +501,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
// });
// }
await sendDocument({
const { Recipient: recipients, ...sentDocument } = await sendDocument({
documentId: Number(id),
userId: user.id,
teamId: team?.id,
sendEmail,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
@ -415,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
status: 200,
body: {
message: 'Document sent for signing successfully',
...sentDocument,
recipients: recipients.map((recipient) => ({
...recipient,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
} catch (err) {
@ -499,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...newRecipient,
documentId: Number(documentId),
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`,
},
};
} catch (err) {
@ -564,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...updatedRecipient,
documentId: Number(documentId),
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`,
},
};
}),
@ -617,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...deletedRecipient,
documentId: Number(documentId),
signingUrl: '',
},
};
}),

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZUrlSchema } from '@documenso/lib/schemas/common';
import {
FieldType,
ReadStatus,
@ -44,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer<
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
export const ZSendDocumentForSigningMutationSchema = null;
export const ZSendDocumentForSigningMutationSchema = z
.object({
sendEmail: z.boolean().optional().default(true),
})
.or(z.literal('').transform(() => ({ sendEmail: true })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
@ -88,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingUrl: z.string(),
}),
),
});
@ -133,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingUrl: z.string(),
}),
),
});
@ -141,6 +152,61 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationResponseSchema
>;
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
title: z.string().optional(),
recipients: z
.array(
z.object({
id: z.number(),
name: z.string().optional(),
email: z.string().email().min(1),
}),
)
.refine(
(schema) => {
const emails = schema.map((signer) => signer.email.toLowerCase());
const ids = schema.map((signer) => signer.id);
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
},
{ message: 'Recipient IDs and emails must be unique' },
),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: ZUrlSchema,
})
.partial()
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
});
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
typeof ZGenerateDocumentFromTemplateMutationSchema
>;
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingUrl: z.string(),
}),
),
});
export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZGenerateDocumentFromTemplateMutationResponseSchema
>;
export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
@ -175,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
sendStatus: z.nativeEnum(SendStatus),
signingUrl: z.string(),
});
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
@ -225,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
export const ZSuccessfulSigningResponseSchema = z.object({
message: z.string(),
});
export const ZSuccessfulSigningResponseSchema = z
.object({
message: z.string(),
})
.and(ZSuccessfulGetDocumentResponseSchema);
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;

View File

@ -41,8 +41,8 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
@ -52,11 +52,7 @@ test.describe('[EE_ONLY]', () => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
await unseedUser(user.id);
});
@ -89,8 +85,8 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
@ -168,11 +164,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});

View File

@ -48,7 +48,7 @@ test.describe('[EE_ONLY]', () => {
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').click();
await page.getByLabel('Show advanced settings').check();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
@ -62,7 +62,6 @@ test.describe('[EE_ONLY]', () => {
});
});
// Note: Not complete yet due to issue with back button.
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
@ -93,26 +92,5 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// // Add advanced settings for a single recipient.
// await page.getByLabel('Show advanced settings').click();
// await page.getByRole('combobox').first().click();
// await page.getByLabel('Require account').click();
// // Navigate to the next step and back.
// await page.getByRole('button', { name: 'Continue' }).click();
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// await page.getByRole('button', { name: 'Go Back' }).click();
// await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id);
});

View File

@ -1,10 +1,14 @@
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -192,6 +196,102 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients with different roles', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Set title
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill('Test Title');
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
await page.locator('button[role="combobox"]').nth(1).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(1).fill('user3@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(2).fill('User 3');
await page.locator('button[role="combobox"]').nth(2).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(2).fill('user4@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(3).fill('User 4');
await page.locator('button[role="combobox"]').nth(3).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'User 1 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByText('User 1 (user1@example.com)').click();
await page.getByText('User 3 (user3@example.com)').click();
await page.getByRole('button', { name: 'User 3 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 500,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 500,
y: 200,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
@ -234,6 +334,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
// Start signing process
const url = page.url().split('/');
const documentId = url[url.length - 1];
@ -263,6 +364,63 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => {
const user = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['user@documenso.com', 'approver@documenso.com'],
recipientsCreateOptions: [
{
email: 'user@documenso.com',
role: RecipientRole.SIGNER,
},
{
email: 'approver@documenso.com',
role: RecipientRole.APPROVER,
},
],
fields: [FieldType.SIGNATURE],
});
for (const recipient of recipients) {
const { token, Field, role } = recipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(
page.getByRole('heading', {
name: role === RecipientRole.SIGNER ? 'Sign Document' : 'Approve Document',
}),
).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
.click();
await page.waitForURL(`${signUrl}/complete`);
}
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
page,
}) => {
@ -333,3 +491,46 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
const user = await seedUser();
const customDate = DateTime.local().toFormat('yyyy-MM-dd hh:mm a');
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['user1@example.com'],
fields: [FieldType.DATE],
});
const { token, Field } = recipients[0];
const [recipientField] = Field;
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}`);
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
const field = await prisma.field.findFirst({
where: {
Recipient: {
email: 'user1@example.com',
},
documentId: Number(document.id),
},
});
expect(field?.customText).toBe(customDate);
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await unseedUser(user.id);
});

View File

@ -13,5 +13,5 @@ export const checkDocumentTabCount = async (page: Page, tabName: string, count:
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${count}`);
};

View File

@ -0,0 +1,167 @@
import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[TEMPLATE_FLOW] add action auth settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
await unseedUser(user.id);
});
test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
await unseedTeam(team.url);
});
test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(teamMemberUser);
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/templates/${template.id}`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
await unseedTeam(team.url);
});
});
test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set title.
await page.getByLabel('Title').fill('New Title');
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});

View File

@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[TEMPLATE_FLOW] add EE settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').check();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Expect that the advanced settings is unchecked, since no advanced settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// Add advanced settings for a single recipient.
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await unseedUser(user.id);
});
});
test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await unseedUser(user.id);
});

View File

@ -0,0 +1,285 @@
import { expect, test } from '@playwright/test';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
/**
* 1. Create a template with all settings filled out
* 2. Create a document from the template
* 3. Ensure all values are correct
*
* Note: There is a direct copy paste of this test below for teams.
*
* If you update this test please update that test as well.
*/
test('[TEMPLATE]: should create a document from a template', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
const recipientOne = document.Recipient[0];
const recipientTwo = document.Recipient[1];
const recipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientOne.authOptions,
});
const recipientTwoAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
});
/**
* This is a direct copy paste of the above test but for teams.
*/
test('[TEMPLATE]: should create a team document from a team template', async ({ page }) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: owner.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
expect(document.teamId).toEqual(team.id);
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
const recipientOne = document.Recipient[0];
const recipientTwo = document.Recipient[1];
const recipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientOne.authOptions,
});
const recipientTwoAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
});

View File

@ -0,0 +1,339 @@
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/template';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
const nanoid = customAlphabet('1234567890abcdef', 10);
test.describe.configure({ mode: 'parallel' });
test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
// Should only be visible to the owner in personal templates.
const personalTemplate = await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
const teamTemplate = await seedTemplate({
title: 'Team template 1',
userId: owner.id,
teamId: team.id,
});
await apiSignin({
page,
email: owner.email,
redirectPath: '/templates',
});
const urls = [
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${teamTemplate.id}`,
`${WEBAPP_BASE_URL}/templates/${personalTemplate.id}`,
];
// Run test for personal and team templates.
for (const url of urls) {
// Owner should see list of templates with no direct link badge.
await page.goto(url);
await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(1);
// Create direct link.
await page.getByRole('button', { name: 'Create Direct Link' }).click();
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
await page.getByRole('button', { name: 'Create one automatically' }).click();
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
await page.getByTestId('btn-dialog-close').click();
// Expect badge to appear.
await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(2);
}
await unseedTeam(team.url);
});
test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
// Should only be visible to the owner in personal templates.
const personalDirectTemplate = await seedDirectTemplate({
title: 'Personal direct template link',
userId: owner.id,
});
// Should be visible to team members.
const teamDirectTemplate = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
await apiSignin({
page,
email: owner.email,
});
// Run test for personal and team templates.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Navigate to template settings and disable access.
await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`);
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Direct link' }).click();
await page.getByRole('switch').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Direct link signing has been').first()).toBeVisible();
await page.getByLabel('Direct Link Signing', { exact: true }).press('Escape');
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('Template not found')).toBeVisible();
}
await unseedTeam(team.url);
});
test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
// Should only be visible to the owner in personal templates.
const personalDirectTemplate = await seedDirectTemplate({
title: 'Personal direct template link',
userId: owner.id,
});
// Should be visible to team members.
const teamDirectTemplate = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
await apiSignin({
page,
email: owner.email,
});
// Run test for personal and team templates.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Navigate to template settings and delete the access.
await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`);
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Direct link' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Direct template link deleted').first()).toBeVisible();
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('Template not found')).toBeVisible();
}
await unseedTeam(team.url);
});
test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => {
const user = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
title: 'Personal direct template link',
userId: user.id,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
}),
},
});
const directTemplatePath = formatDirectTemplatePath(
directTemplateWithAuth.directLink?.token || '',
);
await page.goto(directTemplatePath);
await expect(page.getByText('Authentication required')).toBeVisible();
await apiSignin({
page,
email: user.email,
});
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
await unseedUser(user.id);
});
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
// Should only be visible to the owner in personal templates.
const personalDirectTemplate = await seedDirectTemplate({
title: 'Personal direct template link',
userId: owner.id,
});
// Should be visible to team members.
const teamDirectTemplate = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
// Run test for personal and team templates.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
}
await apiSignin({
page,
email: owner.email,
});
// Check that the owner has the documents.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`);
// Check that the document is in the 'All' tab.
await checkDocumentTabCount(page, 'Completed', 1);
}
await unseedTeam(team.url);
});
test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const secondRecipient = await seedUser();
const createTemplateOptions = {
Recipient: {
createMany: {
data: [
{
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
token: nanoid(),
},
{
email: secondRecipient.email,
token: nanoid(),
},
],
},
},
};
// Should only be visible to the owner in personal templates.
const personalDirectTemplate = await seedDirectTemplate({
title: 'Personal direct template link',
userId: owner.id,
createTemplateOptions,
});
// Should be visible to team members.
const teamDirectTemplate = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
createTemplateOptions,
});
// Run test for personal and team templates.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByText('Waiting for others to sign')).toBeVisible();
}
await apiSignin({
page,
email: owner.email,
});
// Check that the owner has the documents.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`);
// Check that the document is in the 'All' tab.
await checkDocumentTabCount(page, 'All', 1);
await checkDocumentTabCount(page, 'Pending', 1);
}
// Check that the second recipient has the 2 pending documents.
await apiSignin({
page,
email: secondRecipient.email,
});
await page.goto('/documents');
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentTabCount(page, 'Inbox', 2);
await unseedTeam(team.url);
await unseedUser(secondRecipient.id);
});

View File

@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);

Binary file not shown.

View File

@ -3,14 +3,17 @@ import type { TLimitsSchema } from './schema';
export const FREE_PLAN_LIMITS: TLimitsSchema = {
documents: 5,
recipients: 10,
directTemplates: 3,
};
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
directTemplates: Infinity,
};
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
directTemplates: Infinity,
};

View File

@ -10,6 +10,10 @@ export const ZLimitsSchema = z.object({
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
directTemplates: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
});
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;

View File

@ -2,11 +2,12 @@ import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { DocumentSource, SubscriptionStatus } from '@documenso/prisma/client';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import type { TLimitsResponseSchema } from './schema';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
@ -14,7 +15,10 @@ export type GetServerLimitsOptions = {
teamId?: number | null;
};
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
export const getServerLimits = async ({
email,
teamId,
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
@ -74,19 +78,37 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
remaining = structuredClone(quota);
}
}
// Assume all active subscriptions provide unlimited direct templates.
remaining.directTemplates = Infinity;
}
const documents = await prisma.document.count({
where: {
userId: user.id,
teamId: null,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
const [documents, directTemplates] = await Promise.all([
prisma.document.count({
where: {
userId: user.id,
teamId: null,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
source: {
not: DocumentSource.TEMPLATE_DIRECT_LINK,
},
},
},
});
}),
prisma.template.count({
where: {
userId: user.id,
teamId: null,
directLink: {
isNot: null,
},
},
}),
]);
remaining.documents = Math.max(remaining.documents - documents, 0);
remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0);
return {
quota,
@ -127,10 +149,12 @@ const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
quota: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
remaining: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
};
}

View File

@ -5,9 +5,10 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentSource,
DocumentStatus,
FieldType,
ReadStatus,
@ -74,7 +75,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer),
@ -86,6 +87,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
status: DocumentStatus.COMPLETED,
userId: newUser.id,
documentDataId,
source: DocumentSource.DOCUMENT,
},
});

View File

@ -0,0 +1,99 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import config from '@documenso/tailwind-config';
import {
Body,
Button,
Container,
Head,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from '../components';
import TemplateDocumentImage from '../template-components/template-document-image';
import { TemplateFooter } from '../template-components/template-footer';
import { RecipientRole } from '.prisma/client';
export type DocumentCompletedEmailTemplateProps = {
recipientName?: string;
recipientRole?: RecipientRole;
documentLink?: string;
documentName?: string;
assetBaseUrl?: string;
};
export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
recipientName = 'John Doe',
recipientRole = RecipientRole.SIGNER,
documentLink = 'http://localhost:3000',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentCompletedEmailTemplateProps) => {
const action = RECIPIENT_ROLES_DESCRIPTION[recipientRole].actioned.toLowerCase();
const previewText = `Document created from direct template`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{recipientName} {action} a document by using one of your direct links
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-sm text-slate-600">
{documentName}
</div>
<Section className="my-6 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={documentLink}
>
View document
</Button>
</Section>
</Section>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentCreatedFromDirectTemplateEmailTemplate;

View File

@ -18,7 +18,7 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
downloadFile({
filename: `${baseTitle}.pdf`,
filename: `${baseTitle}_signed.pdf`,
data: blob,
});
};

View File

@ -5,12 +5,19 @@ export const SALT_ROUNDS = 12;
export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
[IdentityProvider.DOCUMENSO]: 'Documenso',
[IdentityProvider.GOOGLE]: 'Google',
[IdentityProvider.OIDC]: 'OIDC',
};
export const IS_GOOGLE_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
);
export const IS_OIDC_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_OIDC_WELL_KNOWN &&
process.env.NEXT_PRIVATE_OIDC_CLIENT_ID &&
process.env.NEXT_PRIVATE_OIDC_CLIENT_SECRET,
);
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',

View File

@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.

View File

@ -1,6 +1,6 @@
import { APP_BASE_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 15;
export const DEFAULT_STANDARD_FONT_SIZE = 12;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8;

View File

@ -0,0 +1,28 @@
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
export const DIRECT_TEMPLATE_DOCUMENTATION = [
{
title: 'Enable Direct Link Signing',
description:
'Once enabled, you can select any active recipient to be a direct link signing recipient, or create a new one. This recipient type cannot be edited or deleted.',
},
{
title: 'Configure Direct Recipient',
description:
'Update the role and add fields as required for the direct recipient. The individual who uses the direct link will sign the document as the direct recipient.',
},
{
title: 'Share the Link',
description:
'Once your template is set up, share the link anywhere you want. The person who opens the link will be able to enter their information in the direct link recipient field and complete any other fields assigned to them.',
},
{
title: 'Document Creation',
description:
'After submission, a document will be automatically generated and added to your documents page. You will also receive a notification via email.',
},
];
export const DIRECT_TEMPLATE_RECIPIENT_EMAIL = 'direct.link@documenso.com';
export const DIRECT_TEMPLATE_RECIPIENT_NAME = 'Direct link recipient';

View File

@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client';
@ -11,6 +12,7 @@ export enum AppErrorCode {
'EXPIRED_CODE' = 'ExpiredCode',
'INVALID_BODY' = 'InvalidBody',
'INVALID_REQUEST' = 'InvalidRequest',
'LIMIT_EXCEEDED' = 'LimitExceeded',
'NOT_FOUND' = 'NotFound',
'NOT_SETUP' = 'NotSetup',
'UNAUTHORIZED' = 'Unauthorized',
@ -149,4 +151,24 @@ export class AppError extends Error {
return null;
}
}
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.otherwise(() => 500 as const);
return {
status,
body: {
message: status !== 500 ? error.message : 'Something went wrong',
},
};
}
}

View File

@ -136,6 +136,30 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
};
},
}),
{
id: 'oidc',
name: 'OIDC',
type: 'oauth',
wellKnown: process.env.NEXT_PRIVATE_OIDC_WELL_KNOWN,
clientId: process.env.NEXT_PRIVATE_OIDC_CLIENT_ID,
clientSecret: process.env.NEXT_PRIVATE_OIDC_CLIENT_SECRET,
authorization: { params: { scope: 'openid email profile' } },
checks: ['pkce', 'state'],
idToken: true,
allowDangerousEmailAccountLinking: true,
profile(profile) {
return {
id: profile.sub,
email: profile.email || profile.preferred_username,
name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(),
emailVerified: profile.email_verified ? new Date().toISOString() : null,
};
},
},
CredentialsProvider({
id: 'webauthn',
name: 'Keypass',

View File

@ -16,6 +16,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@auth/kysely-adapter": "^0.6.0",
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/cloudfront-signer": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
@ -27,18 +28,20 @@
"@next-auth/prisma-adapter": "1.0.7",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@node-rs/bcrypt": "^1.10.0",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"@node-rs/bcrypt": "^1.10.0",
"kysely": "^0.26.3",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",
"next": "14.0.3",
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"pg": "^8.11.3",
"playwright": "1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
@ -48,6 +51,7 @@
},
"devDependencies": {
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4",
"@playwright/browser-chromium": "1.43.0"
}
}
}

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { URL_REGEX } from '../constants/url-regex';
/**
* Note this allows empty strings.
*/
export const ZUrlSchema = z
.string()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
});

View File

@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
await sendPendingEmail({ documentId, recipientId: recipient.id });
}
const documents = await prisma.document.updateMany({
const haveAllRecipientsSigned = await prisma.document.findFirst({
where: {
id: document.id,
Recipient: {
@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
},
},
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
if (documents.count > 0) {
if (haveAllRecipientsSigned) {
await sealDocument({ documentId: document.id, requestMetadata });
}

View File

@ -5,7 +5,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -54,6 +54,7 @@ export const createDocument = async ({
userId,
teamId,
formValues,
source: DocumentSource.DOCUMENT,
},
});
@ -65,6 +66,9 @@ export const createDocument = async ({
requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});

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