Compare commits

...

120 Commits
v1.0 ... pr/537

Author SHA1 Message Date
82da337a56 fix: remove templateToken 2023-12-15 22:07:27 +11:00
1eeb5fb103 fix: tidy code and base on main 2023-12-14 15:28:27 +11:00
31a9127c9e feat: templates 2023-12-14 12:24:56 +11:00
6d34ebd91b fix: no longer available client component 2023-12-13 22:49:58 +11:00
e4b7747f66 chore: run eslint fix on lint staged (#743) 2023-12-09 12:37:13 +11:00
0697e7f817 Merge branch 'main' into eslint-lint-staged 2023-12-09 11:41:20 +11:00
e1d3874e79 fix: do not lint js files 2023-12-09 11:40:23 +11:00
497d9140d2 chore: add lint-staged task for dependency changes (#548) 2023-12-09 11:31:01 +11:00
7d22957404 chore: add eslint fix command 2023-12-08 21:27:38 +05:30
38e5b1d3ce chore: use minio as s3 storage for document during development (#588) 2023-12-08 21:08:30 +11:00
09dcc2cac0 chore: update github actions 2023-12-08 20:44:28 +11:00
d8d36ae8e2 fix: sticky header z-positioning 2023-12-08 20:41:52 +11:00
dfec8df31e fix: ensure command menu results are distinct 2023-12-08 20:41:52 +11:00
5e9bc56329 feat: github repo management improvement (#728) 2023-12-08 15:47:55 +11:00
48f6765e76 chore: add yml to lint-staged 2023-12-08 13:01:59 +11:00
7feba02e08 chore: update ci and formatting 2023-12-08 13:01:36 +11:00
4e799e68ef chore: update ci 2023-12-08 09:44:36 +11:00
1831084970 chore: update ci 2023-12-08 09:29:47 +11:00
935601ad16 fix(ee): add handling for incomplete expired checkouts 2023-12-07 18:46:57 +11:00
7b4e38a032 feat: enhance posthog event tracking (#686) 2023-12-07 18:08:14 +11:00
2c5d547cdf fix: add missing import 2023-12-07 18:00:54 +11:00
c313da5028 fix: update seal event 2023-12-07 16:29:20 +11:00
5b98bac53b Merge branch 'main' into feat/enhance-posthog-tracking 2023-12-07 16:28:15 +11:00
d7e44fc068 fix(webapp): use checkout for expired plans (#738)
Currently customers with inactive/expired plans will be shown the
BillingPlans component but upon hitting the subscribe button they will
be redirected to their portal where they are unable to complete a
checkout.

To resolve this we check for expiration of their subscription and use a
normal stripe checkout instead.
2023-12-07 16:19:03 +11:00
e03db5c6b3 feat: handle download file error with toast (#655) 2023-12-07 16:18:30 +11:00
d58433c8a0 fix: destructure toast 2023-12-07 15:50:34 +11:00
419f27536b Merge branch 'main' into feat/download-toast 2023-12-07 15:46:43 +11:00
9a7e5d333d fix: don't expand documentData 2023-12-07 15:45:44 +11:00
39b97a97fe Merge branch 'main' into fix/billing-page 2023-12-07 15:37:32 +11:00
328b2e7604 feat: add stepper component (#718)
feat: add stepper component
2023-12-07 15:36:27 +11:00
43b1a89c76 Merge branch 'main' into main 2023-12-07 15:20:29 +11:00
cd6184406d chore: add e2e test for stepper 2023-12-07 15:08:16 +11:00
1a34f9fa7a fix: import updates and api route body sizes 2023-12-07 15:08:00 +11:00
3ff7b188d7 fix(ui): tidy stepper code 2023-12-07 15:06:49 +11:00
684e5272d2 fix(webapp): use checkout for expired plans 2023-12-07 00:52:36 +00:00
b39a42ecd2 feat(marketing): add careers page (#733) 2023-12-06 18:59:02 +11:00
e81183f324 Merge branch 'main' into main 2023-12-06 14:13:34 +11:00
bfc630aa6a feat: add document search to the command menu (#713) 2023-12-06 12:48:05 +11:00
2068d980ff feat: allow for the deletion of any document (#711)
Allow for the deletion of any document with notifications of document cancellation for pending documents.
2023-12-06 11:11:51 +11:00
0baa2696b4 fix: removed unused code 2023-12-05 10:13:24 +00:00
741201822a fix: use useSession instead of prop drilling 2023-12-05 10:12:28 +00:00
520522bef7 chore: removed repo condition for codeql
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-05 12:05:47 +05:30
8ab1b0cf6b fix: add workspace settings for eol and tabs (#725) 2023-12-05 14:00:48 +11:00
bfedabdc10 fix: increase e2e test timeout (#682) 2023-12-05 13:54:41 +11:00
52e696c90e chore: update pr labeler workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 18:12:43 +05:30
02d91d9cd4 chore: updated workflows
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 18:04:05 +05:30
ac529a89fc feat: check assignee and pr review reminder
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 18:00:28 +05:30
f181099e74 chore: updated workflow permissions and run conditions
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 16:20:02 +05:30
02e96bbd0a feat: added pr count workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 16:08:04 +05:30
36e48e67ee chore: updated issue count workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 16:07:51 +05:30
d8588b780a feat: added issue count workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 15:36:38 +05:30
ef84f5ba98 chore: added EOLs
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 12:28:05 +05:30
68120794f8 feat: added stale workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:29:30 +05:30
88dc797423 chore: update semantic pr workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:29:15 +05:30
a43be0432b feat: add triage issue workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:28:27 +05:30
0f11cc0b4b feat: add first interaction workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:27:49 +05:30
f310139a13 feat: add pr labeler workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:27:17 +05:30
859b789018 feat: isCompleting 2023-12-03 12:50:56 -05:00
340c929806 refactor: edit doc 2023-12-03 11:36:18 -05:00
43b1a14415 chore: let code breath 2023-12-03 11:21:51 -05:00
40a4ec4436 refactor: useContext & remove enum 2023-12-03 01:15:59 -05:00
eccf63dcfd chore: refactor 2023-12-02 23:56:07 -05:00
a98b429052 feat: stepper refactor example 2023-12-02 22:42:59 -05:00
c46a69f865 feat: stepper component 2023-12-02 22:30:10 -05:00
b903de983b chore: v1.2.3 2023-12-02 14:56:00 +11:00
6b519a67c2 fix: add guard 2023-12-02 14:55:26 +11:00
39d18e93c5 chore: v1.2.2 2023-12-02 13:34:36 +11:00
7dac5072f7 fix: revert react-email tailwind canary 2023-12-02 13:34:03 +11:00
fbfaca190b chore: release 1.2.1 2023-12-02 12:43:55 +11:00
486b1cbf62 fix: incorrect promise.all usages 2023-12-02 12:43:43 +11:00
16fb90f4d2 chore: v1.2.0 2023-12-02 11:57:50 +11:00
53cb38a394 fix: pricing page deopted into csr 2023-12-02 11:14:46 +11:00
073a050587 fix: signature field race conditions 2023-12-02 11:09:42 +11:00
39c01f4e8d fix: remove server actions (#684) 2023-12-02 09:38:24 +11:00
335684d0b7 fix: edit document sizing (#706) 2023-12-01 23:09:24 +11:00
792158c2cb feat: add two factor auth (#643)
Add two factor authentication for users who wish to enhance the security of their accounts.
2023-12-01 20:06:32 +11:00
83153cee32 Merge pull request #698 from cuttingedge1109/patch-1
fix: Fix typo in web build command in doc
2023-12-01 15:06:36 +11:00
2d2bdc536e fix: add a script for db seed with env (#700) 2023-12-01 12:12:15 +11:00
c16c36a1fc fix: add a script for db seed with env 2023-11-30 18:38:54 +01:00
1d79ebbda3 fix: body exeeded undefined limit (#679)
* fixed bodySizeLimit

* fix: update marketing config

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2023-11-30 09:46:33 +02:00
252dd0008c feat: add link to homepage on the complete sign page for logged in users (#691)
* feat: add link to homepage on the complete sign page for logged in users

* feat: added ChevronLeft icon to the link

* feat: remove icon from the link
2023-11-30 09:42:15 +02:00
35d0fed8b3 fix: Fix typo in web build command in doc 2023-11-29 20:06:25 +01:00
dad56b4929 fix: minor in file extension (#694) 2023-11-29 09:11:29 +11:00
7e4c44e820 perf(web, lib): do not await inside promise statements (#692) 2023-11-29 09:10:15 +11:00
adc97802ea feat: add/update title of the document (#663) 2023-11-28 14:56:50 +11:00
0e40658201 feat: track when the signing of a document has completed 2023-11-26 19:33:45 +00:00
d347359d2f chore: changes from code review 2023-11-25 22:09:52 +00:00
fdf5b3908d feat: add more posthog analytics to the application 2023-11-24 23:50:51 +00:00
8048c29480 fix: override @react-email/tailwind to avoid perf regression 2023-11-24 23:57:34 +11:00
84b958d5b7 fix: universal upload hitting cache 2023-11-24 20:06:47 +11:00
d8688692f7 fix: move singleplayer create to trpc 2023-11-24 16:58:18 +11:00
8230349114 fix: unable to load font for signing 2023-11-24 16:17:54 +11:00
c054fc78a4 fix: resolve issues with emailVerified jwt property 2023-11-23 15:11:37 +11:00
5de0c464f0 fix: hydration errors for modifier key 2023-11-23 13:57:08 +11:00
9444e0cc67 fix: docker build requires smtp host (#672)
set a default for smtp host and add an action
for testing docker builds on each pull request
2023-11-22 16:26:39 +11:00
be0fe079a3 fix: add healthcheck endpoint (#671) 2023-11-22 15:46:21 +11:00
fbbc3b89c3 feat: email verification for registration (#599) 2023-11-21 15:44:04 +11:00
6c73453542 #666 feat: disabled resend button for recipients (#667) 2023-11-20 21:53:57 +11:00
17eeaa2d25 fix: improve the validation message for documenso app (#640)
* fix: improve the validation message

* fix: improve the validation message

---------

Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2023-11-20 12:23:27 +02:00
a8d49bb8b8 chore: added Documenso video walkthrough (#665) 2023-11-20 19:01:34 +11:00
e077c36fe4 fix: added the zod validation msg in the single player mode (#646) 2023-11-17 14:28:42 +02:00
7ce4cf8381 feat: add dark mode toggle (#529) 2023-11-17 17:01:39 +11:00
cebdf5fd8e chore: custom command menu shortcut text for macOS (#657) 2023-11-17 16:47:19 +11:00
8adc44802f feat: copy signing link from avatar stack (#658) 2023-11-17 16:12:47 +11:00
06714a2aeb chore: append _signed to files when downloading (#656) 2023-11-17 12:02:22 +11:00
1c9cec1e93 fix: remove plausible provider 2023-11-17 10:55:09 +11:00
b4f1a5abce feat: handle download file error with toast 2023-11-16 07:11:23 +00:00
e838a07bf9 chore: remove share button from top level, texts (#653) 2023-11-16 16:43:50 +11:00
8722e4de74 chore: marketing site updates (#654)
Change "Open" links to "Open Startup" to reduce confusion about how to open the web app.

Additionally add the OSS Friends link to the footer since it was dropped during some changes.
2023-11-16 13:39:22 +11:00
f7d8ebb9de feat: enable resend email menu (#496) 2023-11-16 13:08:31 +11:00
67f3b2de45 fix: fetch the correct number of open issues using the github search api (#495) 2023-11-15 21:56:09 +11:00
0da080aa41 fix: hovering on tabslist items at dashboard (#645) 2023-11-15 18:18:11 +11:00
fe25239a4e feat: cache getServerComponentSession calls (#644) 2023-11-15 17:42:27 +11:00
5ea4a16e36 fix: safari pdf overflow issue fixed (#466) 2023-11-15 17:35:57 +11:00
5002a475d1 feat: limit document upload size (#347) 2023-11-15 13:13:31 +11:00
ca9c0d7bf0 chore: add some eslint rules (#344) 2023-11-15 13:10:17 +11:00
3f0341c7d4 feat: add dialog to confirm signing (#342) 2023-11-14 19:01:00 +11:00
c3fe98b05f fix: update docker compose entrypoint (#650) 2023-11-14 13:11:57 +11:00
608a4eaaa6 feat: refactor og image generation (#639) 2023-11-14 13:08:14 +11:00
d6ae0b44e6 feat: use nextjs.js standalone output for improvised docker image (#338) 2023-11-12 13:15:42 +11:00
365 changed files with 69046 additions and 5001 deletions

View File

@ -2,6 +2,11 @@
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
# Application Key for symmetric encryption and decryption
# This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
# [[AUTH OPTIONAL]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
@ -24,15 +29,15 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT=
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION=
NEXT_PRIVATE_UPLOAD_REGION="unknown"
# REQUIRED: Defines the bucket to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_BUCKET=
NEXT_PRIVATE_UPLOAD_BUCKET="documenso"
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
# [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels

View File

@ -1,11 +1,10 @@
name: "Bug Report"
labels: ["bug"]
name: 'Bug Report'
labels: ['bug']
description: Create a bug report to help us improve
body:
- type: markdown
attributes:
value:
Thank you for reporting an issue.
value: Thank you for reporting an issue.
Please fill in as much of the form below as you're able to.
- type: textarea
attributes:

View File

@ -1,9 +1,9 @@
name: "Feature Request"
name: 'Feature Request'
description: Suggest a new idea or enhancement for this project
body:
- type: markdown
attributes:
value: Please provide a clear and concise title for your feature request
value: Please provide a clear and concise title for your feature request
- type: textarea
attributes:
label: Feature Description
@ -32,4 +32,4 @@ body:
- label: I have provided a detailed description of the requested feature.
- label: I have explained the use case or scenario for this feature.
- label: I have included any relevant technical details or design suggestions.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.

View File

@ -1,4 +1,4 @@
name: "General Improvement"
name: 'General Improvement'
description: Suggest a minor enhancement or improvement for this project
body:
- type: markdown
@ -32,4 +32,4 @@ body:
- label: I have provided a clear description of the improvement being suggested.
- label: I have explained the rationale behind this improvement.
- label: I have included any relevant technical details or design suggestions.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.

View File

@ -4,29 +4,29 @@ updates:
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: "weekly"
target-branch: "main"
interval: 'weekly'
target-branch: 'main'
labels:
- "ci dependencies"
- "ci"
- 'ci dependencies'
- 'ci'
open-pull-requests-limit: 0
- package-ecosystem: "npm"
directory: "/apps/marketing"
- package-ecosystem: 'npm'
directory: '/apps/marketing'
schedule:
interval: "weekly"
target-branch: "main"
interval: 'weekly'
target-branch: 'main'
labels:
- "npm dependencies"
- "frontend"
- 'npm dependencies'
- 'frontend'
open-pull-requests-limit: 0
- package-ecosystem: "npm"
directory: "/apps/web"
- package-ecosystem: 'npm'
directory: '/apps/web'
schedule:
interval: "weekly"
target-branch: "main"
interval: 'weekly'
target-branch: 'main'
labels:
- "npm dependencies"
- "frontend"
- 'npm dependencies'
- 'frontend'
open-pull-requests-limit: 0

21
.github/labeler.yml vendored Normal file
View File

@ -0,0 +1,21 @@
'apps: marketing':
- apps/marketing/**
'apps: web':
- apps/web/**
'version bump 👀':
- '**/package.json'
- '**/package-lock.json'
'🚨 migrations 🚨':
- packages/prisma/migrations/**/migration.sql
'🚨 e2e changes 🚨':
- packages/app-tests/e2e/**
'🚨 .env changes 🚨':
- .env.example
'pkg: ee changes':
- packages/ee/**

View File

@ -1,10 +1,10 @@
name: "Continuous Integration"
name: 'Continuous Integration'
on:
push:
branches: [ "main" ]
branches: ['main']
pull_request:
branches: [ "main" ]
branches: ['main']
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@ -14,17 +14,17 @@ env:
HUSKY: 0
jobs:
build:
name: Build
build_app:
name: Build App
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Install Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
@ -37,3 +37,15 @@ jobs:
- name: Build
run: npm run build
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Build Docker Image
run: ./docker/build.sh

View File

@ -1,11 +1,11 @@
name: "CodeQL"
name: 'CodeQL'
on:
workflow_dispatch:
push:
branches: [ "main" ]
branches: ['main']
pull_request:
branches: [ "main" ]
branches: ['main']
jobs:
analyze:
@ -19,30 +19,30 @@ jobs:
strategy:
fail-fast: true
matrix:
language: [ 'javascript' ]
language: ['javascript']
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install Dependencies
run: npm ci
- name: Install Dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- name: Copy env
run: cp .env.example .env
- name: Build Documenso
run: npm run build
- name: Build Documenso
run: npm run build
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

24
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: Deploy to Production
on:
push:
tags:
- '*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Push to release branch
run: |
git checkout release || git checkout -b release
git merge --ff-only main
git push origin release

View File

@ -1,51 +1,50 @@
name: Playwright Tests
on:
push:
branches: [ "main" ]
branches: ['main']
pull_request:
branches: [ "main" ]
branches: ['main']
jobs:
e2e_tests:
name: "E2E Tests"
timeout-minutes: 60
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- name: Start Services
run: npm run dx:up
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Generate Prisma Client
run: npm run prisma:generate -w @documenso/prisma
- name: Create the database
run: npm run prisma:migrate-dev
- name: Seed the database
run: npm run prisma:seed
- name: Run Playwright tests
run: npm run ci
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
name: test-results
path: "packages/app-tests/**/test-results/*"
retention-days: 30
env:
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}

29
.github/workflows/first-interaction.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: 'Welcome New Contributors'
on:
pull_request:
types: ['opened']
issues:
types: ['opened']
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcome Contributors
if: github.event.action == 'opened'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |
Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀
<br /> Feel free to hop into our community in [Discord](https://documen.so/discord)
issue-message: |
Thank you for opening your first issue and for being a part of the open signing revolution!
<br /> One of our team members will review it and get back to you as soon as it possible 💚
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)

View File

@ -0,0 +1,59 @@
name: 'Issue Assignee Check'
on:
issues:
types: ['assigned']
permissions:
issues: write
jobs:
countIssues:
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
runs-on: ubuntu-latest
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: npm
- name: Install Octokit
run: npm install @octokit/rest@18
- name: Check Assigned User's Issue Count
id: parse-comment
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const username = context.payload.issue.assignee.login;
console.log(`Username Extracted: ${username}`);
const { data: issues } = await octokit.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
assignee: username,
state: 'open'
});
const issueCount = issues.length;
console.log(`Issue Count For ${username}: ${issueCount}`);
if (issueCount > 3) {
let issueCountMessage = `### 🚨 Documenso Police 🚨`;
issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`;
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: issueCountMessage,
headers: {
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
}
});
}

21
.github/workflows/issue-opened.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: 'Label Issues'
on:
issues:
types: ['opened', 'reopened']
jobs:
label_issues:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v6
with:
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["needs triage"]
})

20
.github/workflows/pr-labeler.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: 'PR Labeler'
on:
- pull_request_target
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
sync-labels: ''

View File

@ -0,0 +1,60 @@
name: 'PR Review Reminder'
on:
pull_request:
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
permissions:
pull-requests: write
jobs:
checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
runs-on: ubuntu-latest
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: npm
- name: Install Octokit
run: npm install @octokit/rest@18
- name: Check user's PRs awaiting review
id: parse-prs
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const username = context.payload.pull_request.user.login;
console.log(`Username Extracted: ${username}`);
const { data: pullRequests } = await octokit.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'created',
direction: 'asc',
});
const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review'));
const prCount = userPullRequests.length;
console.log(`PR Count for ${username}: ${prCount}`);
if (prCount > 3) {
let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`;
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: prReminderMessage,
headers: {
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
}
});
}

View File

@ -1,4 +1,4 @@
name: "Validate PR Name"
name: 'Validate PR Name'
on:
pull_request_target:
@ -9,7 +9,7 @@ on:
- synchronize
permissions:
pull-requests: read
pull-requests: write
jobs:
validate-pr:
@ -17,5 +17,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey There! and thank you for opening this pull request! 📝👋🏼
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 💚🚀

25
.github/workflows/stale.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: 'Mark Stale Issues and PRs'
on:
schedule:
- cron: '0 */8 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 30
days-before-issue-stale: 30
stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected'
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
close-issue-message: 'This issue has been closed because of inactivity.'
close-pr-message: 'This PR has been closed because of inactivity.'
exempt-pr-labels: 'WIP,on-hold,needs review'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned'

16
.prettierignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
.next
public
**/**/node_modules
**/**/.next
**/**/public
*.lock
*.log
*.test.ts
.gitignore
.npmignore
.prettierignore
.DS_Store
.eslintignore

View File

@ -6,5 +6,8 @@
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.useAliasesForRenames": false,
"typescript.enablePromptUseWorkspaceTsdk": true
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.eol": "\n",
"editor.tabSize": 2,
"editor.insertSpaces": true
}

View File

@ -139,11 +139,13 @@ npm run d
1. **App** - http://localhost:3000
2. **Incoming Mail Access** - http://localhost:9000
3. **Database Connection Details**
- **Port**: 54320
- **Connection**: Use your favorite database client to connect using the provided port.
4. **S3 Storage Dashboard** - http://localhost:9001
## Developer Setup
### Manual Setup
@ -193,6 +195,12 @@ git clone https://github.com/documenso/documenso
We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso)
### Video walkthrough
If you're a visual learner and prefer to watch a video walkthrough of setting up Documenso locally, check out this video:
[![Watch the video](https://img.youtube.com/vi/Y0ppIQrEnZs/hqdefault.jpg)](https://youtu.be/Y0ppIQrEnZs)
## Docker
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
@ -234,7 +242,7 @@ Now you can install the dependencies and build it:
```
npm i
npm run:build:web
npm run build:web
npm run prisma:migrate-deploy
```

View File

@ -1,51 +1,51 @@
---
title: Announcing Documenso
description: Launching an open-source document signing tool because trusted-based products should be built on openness. The first release will be in 2023. Sign up at documenso.com to be on board.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2022-12-29
tags:
- Announcement
---
<figure>
<MdxNextImage
src="/blog/blog-banner-announcing-documenso.webp"
width="1400"
height="884"
alt="Documenso announcement blog banner"
/>
<figcaption className="text-center">Documenso — The Open Source DocuSign Alternative.</figcaption>
</figure>
## TL; DR;
I'm launching an open source document signing tool because trust-based products should be built on openness. The first release will be in 2023. Sign up at <a href="https://documenso.com" target="_blank">documenso.com</a> and get on board.
## Lets build the worlds most trusted document-signing tool.
Today I'm excited to announce my new project Documenso. Documenso is an open source document signing tool you can host yourself and freely build upon because it's, you know, open source. Before I get more into the details of what and when will be launched I want to take a moment and talk about why.
## Digital signing is great
Signing Documents digitally has countless benefits: Less struggle with printing, less wasting paper, faster request delivery, easier changes, easier coordination of people far away, verifiable document integrity, and verifiable signer identity (this is a vast topic, will write more on soon), easier storage and search of signed documents, the list goes on. Digital Signatures take something very old and very trusted like personally signing documents into the digital space, adding the benefits listed above. It also introduces a new party to every signing transaction, the signing tool providers. What was peer to peer transaction before, now goes through an intermediary. While this isn't a problem in itself, it should make us think about how we want these providers of trust to work.
## How do we build trusted systems?
While doing research for Documenso I came upon a quote that expresses the current state of document signing pretty well:
> Document signing is NOT a technical problem. [Editors Note: Because it was solved technically a long time ago] Its a legal acceptance problem — and everyone KNOWS DocuSign and friends and understands how theyre admissible. Anything else would have to compete with that and people would be suspicious of it for a long time.
While this may sound like a hurdle at first, it immediately gave me a sense of validation for a more open approach to signing. People will and should be suspicious of their tools and demand a high bar when it comes to trust. And the way to earn this trust is by being open. Trusted tools should be the result of thoughtful discussion and reviews. They should be the result of the needs and will of its community. They should be transparent, adaptable, and empowering while using. Open Source embodies these values very well for software, which makes it a perfect fit for this space and creating a high-trust tool.
## Next Steps
So, what can you expect from here on out? I've started to build Documenso 0.1 which is scheduled to release in “early” 2023. If you're interested in helping make this happen, let me know via [hi@documenso.com](mailto:hi@documenso.com). Getting working code into the hands of the perspective Documenso community is currently the #1 goal. Other than that I'll be releasing several articles about document signing and what something like Documenso should look like, in my humble opinion. So stay tuned!
If you think Documenso is worthy of support, please share <a href="https://documenso.com" target="_blank">documenso.com</a> with anyone interested, and sign up to be among the first to try out version 0.1 as soon as it launches.
Cheers from Hamburg
Timur
---
title: Announcing Documenso
description: Launching an open-source document signing tool because trusted-based products should be built on openness. The first release will be in 2023. Sign up at documenso.com to be on board.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2022-12-29
tags:
- Announcement
---
<figure>
<MdxNextImage
src="/blog/blog-banner-announcing-documenso.webp"
width="1400"
height="884"
alt="Documenso announcement blog banner"
/>
<figcaption className="text-center">Documenso — The Open Source DocuSign Alternative.</figcaption>
</figure>
## TL; DR;
I'm launching an open source document signing tool because trust-based products should be built on openness. The first release will be in 2023. Sign up at <a href="https://documenso.com" target="_blank">documenso.com</a> and get on board.
## Lets build the worlds most trusted document-signing tool.
Today I'm excited to announce my new project Documenso. Documenso is an open source document signing tool you can host yourself and freely build upon because it's, you know, open source. Before I get more into the details of what and when will be launched I want to take a moment and talk about why.
## Digital signing is great
Signing Documents digitally has countless benefits: Less struggle with printing, less wasting paper, faster request delivery, easier changes, easier coordination of people far away, verifiable document integrity, and verifiable signer identity (this is a vast topic, will write more on soon), easier storage and search of signed documents, the list goes on. Digital Signatures take something very old and very trusted like personally signing documents into the digital space, adding the benefits listed above. It also introduces a new party to every signing transaction, the signing tool providers. What was peer to peer transaction before, now goes through an intermediary. While this isn't a problem in itself, it should make us think about how we want these providers of trust to work.
## How do we build trusted systems?
While doing research for Documenso I came upon a quote that expresses the current state of document signing pretty well:
> Document signing is NOT a technical problem. [Editors Note: Because it was solved technically a long time ago] Its a legal acceptance problem — and everyone KNOWS DocuSign and friends and understands how theyre admissible. Anything else would have to compete with that and people would be suspicious of it for a long time.
While this may sound like a hurdle at first, it immediately gave me a sense of validation for a more open approach to signing. People will and should be suspicious of their tools and demand a high bar when it comes to trust. And the way to earn this trust is by being open. Trusted tools should be the result of thoughtful discussion and reviews. They should be the result of the needs and will of its community. They should be transparent, adaptable, and empowering while using. Open Source embodies these values very well for software, which makes it a perfect fit for this space and creating a high-trust tool.
## Next Steps
So, what can you expect from here on out? I've started to build Documenso 0.1 which is scheduled to release in “early” 2023. If you're interested in helping make this happen, let me know via [hi@documenso.com](mailto:hi@documenso.com). Getting working code into the hands of the perspective Documenso community is currently the #1 goal. Other than that I'll be releasing several articles about document signing and what something like Documenso should look like, in my humble opinion. So stay tuned!
If you think Documenso is worthy of support, please share <a href="https://documenso.com" target="_blank">documenso.com</a> with anyone interested, and sign up to be among the first to try out version 0.1 as soon as it launches.
Cheers from Hamburg
Timur

View File

@ -1,29 +1,29 @@
---
title: The Documenso Manifest
description: Signing documents is a fundamental building block of private, economic, and government interactions. Access to easy and secure signing to participate in society should therefore be a fundamental right for everyone. The technology to enable this should be accessible and widespread.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-07-13
tags:
- Manifesto
---
<figure>
<MdxNextImage
src="/blog/blog-banner-manifest.jpeg"
width="1260"
height="630"
alt="The Documenso Manifest blog banner"
/>
<figcaption className="text-center">
Documenso — The Open Source DocuSign Alternative.
</figcaption>
</figure>
Signing documents is a fundamental building block of private, economic, and government interactions. Access to easy and secure signing to participate in society should therefore be a fundamental right for everyone. The technology to enable this should be accessible and widespread.
We know that open source is the key to solving this need once and for all to benefit all humankind. Using open source kickstarts innovation by putting the open sharing of ideas and solutions first. With Documenso, we will create an open and globally accessible signing platform to empower users, customers, and developers to fulfill their needs. Documenso is built by and for the global community, listening and implementing what is needed. Being transparent with the code and the processes that use it brings trust and security to the platform.
We build Documenso for longevity and scale by embracing the capital efficiency and inclusiveness of the Commercial Open Source (COSS) movement. We are building a global commodity for the world.
---
title: The Documenso Manifest
description: Signing documents is a fundamental building block of private, economic, and government interactions. Access to easy and secure signing to participate in society should therefore be a fundamental right for everyone. The technology to enable this should be accessible and widespread.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-07-13
tags:
- Manifesto
---
<figure>
<MdxNextImage
src="/blog/blog-banner-manifest.jpeg"
width="1260"
height="630"
alt="The Documenso Manifest blog banner"
/>
<figcaption className="text-center">
Documenso — The Open Source DocuSign Alternative.
</figcaption>
</figure>
Signing documents is a fundamental building block of private, economic, and government interactions. Access to easy and secure signing to participate in society should therefore be a fundamental right for everyone. The technology to enable this should be accessible and widespread.
We know that open source is the key to solving this need once and for all to benefit all humankind. Using open source kickstarts innovation by putting the open sharing of ideas and solutions first. With Documenso, we will create an open and globally accessible signing platform to empower users, customers, and developers to fulfill their needs. Documenso is built by and for the global community, listening and implementing what is needed. Being transparent with the code and the processes that use it brings trust and security to the platform.
We build Documenso for longevity and scale by embracing the capital efficiency and inclusiveness of the Commercial Open Source (COSS) movement. We are building a global commodity for the world.

View File

@ -11,26 +11,28 @@ tags:
- Open Startup
---
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
## Two more for the road (to open signing)
We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso.
## Open Source, Open Metrics
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
The two main takeaways are:
- "Any Startup that shares its metrics as open as technically and operationally possible is an Open Startup."
- "Why should I care? Frankly speaking, Open Startups have a tough time screwing you over."
The more open the culture, the less shady stuff is going on. While this may sound trivial, the implications are profound. A new generation of organizations, operating more ethically and responsibly simply because everything is out in the open.
The more open the culture, the less shady stuff is going on. While this may sound trivial, the implications are profound. A new generation of organizations, operating more ethically and responsibly simply because everything is out in the open.
For us, there are two sides to being an Open Startup:
- The company side: Sharing Financial KPIs like growth, funding, team structure, salary, internal processes, and tools.
- The product side: Sharing insights and data like usage, reach, and GitHub activity.
Both sides aim to contribute to the global knowledge base of how startups work, specifically COSS startups. As we see more and more COSS, best practices and business insights should be broadly available to let the space mature. As we contribute code to the global community, we also contribute our business knowledge to help bring about even more COSS.
Both sides aim to contribute to the global knowledge base of how startups work, specifically COSS startups. As we see more and more COSS, best practices and business insights should be broadly available to let the space mature. As we contribute code to the global community, we also contribute our business knowledge to help bring about even more COSS.
Starting today, we're releasing a lot of our data as part of the Open Startup movement. You can find the juicy details on our funding and more here: [documen.so/open](https://documen.so/open)

View File

@ -24,9 +24,9 @@ Were an open-source project and focus on building a great developer experienc
So, were switching all conversations, team and community-wide, to Discord.
In this post, we wont debate *why* were switching — Slack vs. Discord is a long-lasting debate with pros and cons, and fans on both sides. There are great [stories](https://blog.meilisearch.com/from-slack-to-discord-our-migration/) and [threads](https://twitter.com/McPizza0/status/1655519558600470528) on the topic. We just dont want to write yet another story here.
In this post, we wont debate _why_ were switching — Slack vs. Discord is a long-lasting debate with pros and cons, and fans on both sides. There are great [stories](https://blog.meilisearch.com/from-slack-to-discord-our-migration/) and [threads](https://twitter.com/McPizza0/status/1655519558600470528) on the topic. We just dont want to write yet another story here.
Instead, well focus on *how* we plan to make the switch.
Instead, well focus on _how_ we plan to make the switch.
## Who is this story for?
@ -38,7 +38,7 @@ For founders and makers who would like to switch too, in one way or another, thi
## Switching to Discord
Were switching to Discord, step by step. First, were moving team conversations, then were moving the community with a 15-day buffering.
Were switching to Discord, step by step. First, were moving team conversations, then were moving the community with a 15-day buffering.
The detailed plan goes like this:
@ -46,90 +46,91 @@ The detailed plan goes like this:
- 2023-07-26 `t+1`: The team switches to Discord. The objective is to get used to the product and to customize it to feel at home and, when were ready to welcome the community, to make new members feel at home, too.
- 2023-08-02 `t+8`: We announce to the community the upcoming changes in the different channels — GitHub, Twitter, and Slack.
- **GitHub**
- Create new Pull Request
- Add story to the blog
- Update link to the community
```
https://documen.so/discord
```
- Start a new Discussion
```markdown
Happy Wednesday!
TL,DR: Were switching to Discord. [Join the fun!](https://documen.so/discord)
We want to build a beautiful, open-source DocuSign alternative. As we're growing (reached 2.3K Stars), we feel the need to have a more community- and developer-friendly environment to share ideas, support, and memes.
Make sure to join the server to keep up to date on all things Documenso.
Oh and, spoiler alert, there may be some swag there 👀
See you there!
Flo
```
- **Twitter**
- [Tweet the announcement](https://twitter.com/documenso/status/1686719482096766977)
- Pin Tweet
- Update link in bio
```
The Open Source DocuSign Alternative.
http://documen.so/github
http://documen.so/discord
http://documen.so/manifest
```
- **Slack**
- Post message in `#general`
```markdown
Happy Wednesday!
TL,DR: Were switching to Discord. [Join the fun!](https://documen.so/discord)
We want to build a beautiful, open-source DocuSign alternative. As we're growing (reached 2.3K Stars), we feel the need to have a more community- and developer-friendly environment to share ideas, support, and memes.
Make sure to [join the server](https://documen.so/discord) to keep up to date on all things Documenso.
Oh and, spoiler alert, there may be some swag there 👀
See you there!
Flo
```
- Pin post
- Set topic and description
```
We're switching to Discord. Join the fun: https://documen.so/discord
```
- Archive channels: `#code-review` `#how-to` `#meet-and-greet` `#random-memes` `#self-hosting` `#support`
- **GitHub**
- Create new Pull Request
- Add story to the blog
- Update link to the community
```
https://documen.so/discord
```
- Start a new Discussion
```markdown
Happy Wednesday!
TL,DR: Were switching to Discord. [Join the fun!](https://documen.so/discord)
We want to build a beautiful, open-source DocuSign alternative. As we're growing (reached 2.3K Stars), we feel the need to have a more community- and developer-friendly environment to share ideas, support, and memes.
Make sure to join the server to keep up to date on all things Documenso.
Oh and, spoiler alert, there may be some swag there 👀
See you there!
Flo
```
- **Twitter**
- [Tweet the announcement](https://twitter.com/documenso/status/1686719482096766977)
- Pin Tweet
- Update link in bio
```
The Open Source DocuSign Alternative.
http://documen.so/github
http://documen.so/discord
http://documen.so/manifest
```
- **Slack**
- Post message in `#general`
```markdown
Happy Wednesday!
TL,DR: Were switching to Discord. [Join the fun!](https://documen.so/discord)
We want to build a beautiful, open-source DocuSign alternative. As we're growing (reached 2.3K Stars), we feel the need to have a more community- and developer-friendly environment to share ideas, support, and memes.
Make sure to [join the server](https://documen.so/discord) to keep up to date on all things Documenso.
Oh and, spoiler alert, there may be some swag there 👀
See you there!
Flo
```
- Pin post
- Set topic and description
```
We're switching to Discord. Join the fun: https://documen.so/discord
```
- Archive channels: `#code-review` `#how-to` `#meet-and-greet` `#random-memes` `#self-hosting` `#support`
- 2023-08-09 `t+15`: 7 days later, we send a reminder on Slack.
- **Slack**
- Schedule reminder in `#general`
```
Friendly reminder: we're switching to Discord and will soon disconnect this Slack workspace.
Join the fun! https://documen.so/discord
```
- **Slack**
- Schedule reminder in `#general`
```
Friendly reminder: we're switching to Discord and will soon disconnect this Slack workspace.
Join the fun! https://documen.so/discord
```
- 2023-08-16 `t+22`: 15 days later, were making the final edits to the Slack workspace.
- **Slack**
- [Edit posting permissions](https://app.slack.com/slackhelp/en-US/360004635551) in `#general`
- Disconnect Slack
- **Slack**
- [Edit posting permissions](https://app.slack.com/slackhelp/en-US/360004635551) in `#general`
- Disconnect Slack
## Final thoughts
- Were at the very, early stage on our journey to building a beautiful, open-source DocuSign alternative. We want to build a great developer experience with the open-source community and, switching to Discord, we want to set up the foundations of an open, safe place for community members to get in touch, brainstorm ideas, and have fun.
- It doesnt mean we wont ever switch back to Slack. The tools of today arent the ones of tomorrow. We dont delete the Slack workspace, we archive it, and keep the `documenso` handle. May it be just an *au revoir?*
- It doesnt mean we wont ever switch back to Slack. The tools of today arent the ones of tomorrow. We dont delete the Slack workspace, we archive it, and keep the `documenso` handle. May it be just an _au revoir?_
- For now, were pushing forward and are eager to welcome you on Discord. Make sure to [join the server](https://documen.so/discord) in order to keep up to date on all things Documenso. See you there!

View File

@ -0,0 +1,13 @@
---
title: Careers at Documenso
---
# Careers at Documenso
So you love Documenso and all the things that we do and now you want to work with us to unlock the future of open signing?
---
## Open Positions
Unfortunately we have no open positions available at the moment. Our team has grown and so we must grow with it, please check back from time to time as now is not forever and we may be hiring again in the future.

View File

@ -1,256 +1,256 @@
---
title: Privacy Policy
---
# Privacy Policy
Effective date: 05/28/2023
### 1\. Introduction
Welcome to **Documenso Inc.**
Documenso Inc. (“us”, “we”, or “our”) operates [https://documenso.com](https://documenso.com) (hereinafter referred to as “ **Service**”).
Our Privacy Policy governs your visit to [https://documenso.com](https://documenso.com), and explains how we collect, safeguard and disclose information that results from your use of our Service.
We use your data to provide and improve Service. By using Service, you agree to the collection and use of information in accordance with this policy. Unless otherwise defined in this Privacy Policy, the terms used in this Privacy Policy have the same meanings as in our Terms and Conditions.
Our Terms and Conditions (“**Terms**”) govern all use of our Service and together with the Privacy Policy constitutes your agreement with us (“ **agreement**”).
### 2\. Definitions
**SERVICE** means the https://documenso.com website operated by Documenso Inc.
**PERSONAL DATA** means data about a living individual who can be identified from those data (or from those and other information either in our possession or likely to come into our possession).
**USAGE DATA** is data collected automatically either generated by the use of Service or from Service infrastructure itself (for example, the duration of a page visit).
**COOKIES** are small files stored on your device (computer or mobile device).
**DATA CONTROLLER** means a natural or legal person who (either alone or jointly or in common with other persons) determines the purposes for which and the manner in which any personal data are, or are to be, processed. For the purpose of this Privacy Policy, we are a Data Controller of your data.
**DATA PROCESSORS (OR SERVICE PROVIDERS)** means any natural or legal person who processes the data on behalf of the Data Controller. We may use the services of various Service Providers in order to process your data more effectively.
**DATA SUBJECT** is any living individual who is the subject of Personal Data.
**THE USER** is the individual using our Service. The User corresponds to the Data Subject, who is the subject of Personal Data.
### 3\. Information Collection and Use
We collect several different types of information for various purposes to provide and improve our Service to you.
### 4\. Types of Data Collected
**Personal Data**
While using our Service, we may ask you to provide us with certain personally identifiable information that can be used to contact or identify you (“**Personal Data**”). Personally identifiable information may include, but is not limited to:
1. Email address
2. First name and last name
3. Cookies and Usage Data
We may use your Personal Data to contact you with newsletters, marketing or promotional materials and other information that may be of interest to you. You may opt out of receiving any, or all, of these communications from us by following the unsubscribe link.
**Usage Data**
We may also collect information that your browser sends whenever you visit our Service or when you access Service by or through a mobile device (“**Usage Data**”).
This Usage Data may include information such as your computer's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that you visit, the time and date of your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
When you access Service with a mobile device, this Usage Data may include information such as the type of mobile device you use, your mobile device unique ID, the IP address of your mobile device, your mobile operating system, the type of mobile Internet browser you use, unique device identifiers and other diagnostic data.
**Tracking Cookies Data**
We use cookies and similar tracking technologies to track the activity on our Service and we hold certain information.
Cookies are files with a small amount of data which may include an anonymous unique identifier. Cookies are sent to your browser from a website and stored on your device. Other tracking technologies are also used such as beacons, tags and scripts to collect and track information and to improve and analyze our Service.
You can instruct your browser to refuse all cookies or to indicate when a cookie is being sent. However, if you do not accept cookies, you may not be able to use some portions of our Service.
Examples of Cookies we use:
1. **Session Cookies:** We use Session Cookies to operate our Service.
2. **Preference Cookies:** We use Preference Cookies to remember your preferences and various settings.
3. **Security Cookies:** We use Security Cookies for security purposes.
4. **Advertising Cookies:** Advertising Cookies are used to serve you with advertisements that may be relevant to you and your interests.
### 5\. Use of Data
Documenso Inc. uses the collected data for various purposes:
1. to provide and maintain our Service;
2. to notify you about changes to our Service;
3. to allow you to participate in interactive features of our Service when you choose to do so;
4. to provide customer support;
5. to gather analysis or valuable information so that we can improve our Service;
6. to monitor the usage of our Service;
7. to detect, prevent and address technical issues;
8. to fulfill any other purpose for which you provide it;
9. to carry out our obligations and enforce our rights arising from any contracts entered into between you and us, including for billing and collection;
10. to provide you with notices about your account and/or subscription, including expiration and renewal notices, email-instructions, etc.;
11. to provide you with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless you have opted not to receive such information;
12. in any other way we may describe when you provide the information;
13. for any other purpose with your consent.
### 6\. Retention of Data
We will retain your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.
We will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period, except when this data is used to strengthen the security or to improve the functionality of our Service, or we are legally obligated to retain this data for longer time periods.
### 7\. Transfer of Data
Your information, including Personal Data, may be transferred to and maintained on computers located outside of your state, province, country or other governmental jurisdiction where the data protection laws may differ from those of your jurisdiction.
If you are located outside United States and choose to provide information to us, please note that we transfer the data, including Personal Data, to United States and process it there.
Your consent to this Privacy Policy followed by your submission of such information represents your agreement to that transfer.
Documenso Inc. will take all the steps reasonably necessary to ensure that your data is treated securely and in accordance with this Privacy Policy and no transfer of your Personal Data will take place to an organisation or a country unless there are adequate controls in place including the security of your data and other personal information.
### 8\. Disclosure of Data
We may disclose personal information that we collect, or you provide:
1. **Disclosure for Law Enforcement.**
2. Under certain circumstances, we may be required to disclose your Personal Data if required to do so by law or in response to valid requests by public authorities.
3. **Business Transaction.**
4. If we or our subsidiaries are involved in a merger, acquisition or asset sale, your Personal Data may be transferred.
5. **Other cases. We may disclose your information also:**
1. to our subsidiaries and affiliates;
2. to contractors, service providers, and other third parties we use to support our business;
3. to fulfill the purpose for which you provide it;
### 9\. Security of Data
The security of your data is important to us but remember that no method of transmission over the Internet or method of electronic storage is 100% secure. While we strive to use commercially acceptable means to protect your Personal Data, we cannot guarantee its absolute security.
### 10\. Your Data Protection Rights Under General Data Protection Regulation (GDPR)
If you are a resident of the European Union (EU) and European Economic Area (EEA), you have certain data protection rights, covered by GDPR. See more at [https://eur-lex.europa.eu/eli/reg/2016/679/oj](https://eur-lex.europa.eu/eli/reg/2016/679/oj)
We aim to take reasonable steps to allow you to correct, amend, delete, or limit the use of your Personal Data.
If you wish to be informed what Personal Data we hold about you and if you want it to be removed from our systems, please email us at hi@documenso.com.
In certain circumstances, you have the following data protection rights:
1. the right to access, update or to delete the information we have on you;
2. the right of rectification. You have the right to have your information rectified if that information is inaccurate or incomplete;
3. the right to object. You have the right to object to our processing of your Personal Data;
4. the right of restriction. You have the right to request that we restrict the processing of your personal information;
5. the right to data portability. You have the right to be provided with a copy of your Personal Data in a structured, machine-readable and commonly used format;
6. the right to withdraw consent. You also have the right to withdraw your consent at any time where we rely on your consent to process your personal information;
Please note that we may ask you to verify your identity before responding to such requests. Please note, we may not able to provide Service without some necessary data.
You have the right to complain to a Data Protection Authority about our collection and use of your Personal Data. For more information, please contact your local data protection authority in the European Economic Area (EEA).
### 11\. Your Data Protection Rights under the California Privacy Protection Act (CalOPPA)
CalOPPA is the first state law in the nation to require commercial websites and online services to post a privacy policy. The laws reach stretches well beyond California to require a person or company in the United States (and conceivable the world) that operates websites collecting personally identifiable information from California consumers to post a conspicuous privacy policy on its website stating exactly the information being collected and those individuals with whom it is being shared, and to comply with this policy. See more at: [https://consumercal.org/about-cfc/cfc-education-foundation/california-online-privacy-protection-act-caloppa-3/](https://consumercal.org/about-cfc/cfc-education-foundation/california-online-privacy-protection-act-caloppa-3/)
According to CalOPPA we agree to the following:
1. users can visit our site anonymously;
2. our Privacy Policy link includes the word “Privacy”, and can easily be found on the page specified above on the home page of our website;
3. users will be notified of any privacy policy changes on our Privacy Policy Page;
4. users are able to change their personal information by emailing us at hi@documenso.com.
Our Policy on “Do Not Track” Signals:
We honor Do Not Track signals and do not track, plant cookies, or use advertising when a Do Not Track browser mechanism is in place. Do Not Track is a preference you can set in your web browser to inform websites that you do not want to be tracked.
You can enable or disable Do Not Track by visiting the Preferences or Settings page of your web browser.
### 12\. Your Data Protection Rights under the California Consumer Privacy Act (CCPA)
If you are a California resident, you are entitled to learn what data we collect about you, ask to delete your data and not to sell (share) it. To exercise your data protection rights, you can make certain requests and ask us:
1. **What personal information we have about you**. If you make this request, we will return to you:
1. The categories of personal information we have collected about you.
2. The categories of sources from which we collect your personal information.
3. The business or commercial purpose for collecting or selling your personal information.
4. The categories of third parties with whom we share personal information.
5. The specific pieces of personal information we have collected about you.
6. A list of categories of personal information that we have sold, along with the category of any other company we sold it to. If we have not sold your personal information, we will inform you of that fact.
7. A list of categories of personal information that we have disclosed for a business purpose, along with the category of any other company we shared it with.
Please note, you are entitled to ask us to provide you with this information up to two times in a rolling twelve-month period. When you make this request, the information provided may be limited to the personal information we collected about you in the previous 12 months.
2. **To delete your personal information**. If you make this request, we will delete the personal information we hold about you as of the date of your request from our records and direct any service providers to do the same. In some cases, deletion may be accomplished through de-identification of the information. If you choose to delete your personal information, you may not be able to use certain functions that require your personal information to operate.
3. **To stop selling your personal information**. We don't sell or rent your personal information to any third parties for any purpose. You are the only owner of your Personal Data and can request disclosure or deletion at any time.
Please note, if you ask us to delete or stop selling your data, it may impact your experience with us, and you may not be able to participate in certain programs or membership services which require the usage of your personal information to function. But in no circumstances, we will discriminate against you for exercising your rights.
To exercise your California data protection rights described above, please send your request(s) by one of the following means:
By email: hi@documenso.com
Your data protection rights, described above, are covered by the CCPA, short for the California Consumer Privacy Act. To find out more, visit the official [California Legislative Information website](https://leginfo.legislature.ca.gov/faces/billTextClient.xhtml?bill_id=201720180AB375). The CCPA took effect on 01/01/2020.
### 13\. Service Providers
We may employ third party companies and individuals to facilitate our Service (“ **Service Providers**”), provide Service on our behalf, perform Service-related services or assist us in analysing how our Service is used.
These third parties have access to your Personal Data only to perform these tasks on our behalf and are obligated not to disclose or use it for any other purpose.
### 14\. Analytics
We may use third-party Service Providers to monitor and analyze the use of our Service.
**Plausible Analytics**
Plausible Analytics is an analytics service provided by Conva Ventures Inc. You can find their Privacy Policy here: [https://plausible.io/privacy](https://plausible.io/privacy)
### 15\. CI/CD tools
We may use third-party Service Providers to automate the development process of our Service.
**GitHub**
GitHub is provided by GitHub, Inc.
GitHub is a development platform to host and review code, manage projects, and build software.
For more information on what data GitHub collects for what purpose and how the protection of the data is ensured, please visit GitHub Privacy Policy page: [https://help.github.com/en/articles/github-privacy-statement](https://help.github.com/en/articles/github-privacy-statement) .
### 16\. Payments
We may provide paid products and/or services within Service. In that case, we use third-party services for payment processing (e.g. payment processors).
We will not store or collect your payment card details. That information is provided directly to our third-party payment processors whose use of your personal information is governed by their Privacy Policy. These payment processors adhere to the standards set by PCI-DSS as managed by the PCI Security Standards Council, which is a joint effort of brands like Visa, Mastercard, American Express and Discover. PCI-DSS requirements help ensure the secure handling of payment information.
The payment processors we work with are:
**Stripe:**
Their Privacy Policy can be viewed at: [https://stripe.com/us/privacy](https://stripe.com/us/privacy)
### 17\. Links to Other Sites
Our Service may contain links to other sites that are not operated by us. If you click a third party link, you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit.
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
### 18\. Children's Privacy
Our Services are not intended for use by children under the age of 18 (“ **Child**” or “**Children**”).
We do not knowingly collect personally identifiable information from Children under 18. If you become aware that a Child has provided us with Personal Data, please contact us. If we become aware that we have collected Personal Data from Children without verification of parental consent, we take steps to remove that information from our servers.
### 19\. Changes to This Privacy Policy
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
We will let you know via email and/or a prominent notice on our Service, prior to the change becoming effective and update “effective date” at the top of this Privacy Policy.
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.
### 20\. Contact Us
If you have any questions about this Privacy Policy, please contact us:
By email: hi@documenso.com.
---
title: Privacy Policy
---
# Privacy Policy
Effective date: 05/28/2023
### 1\. Introduction
Welcome to **Documenso Inc.**
Documenso Inc. (“us”, “we”, or “our”) operates [https://documenso.com](https://documenso.com) (hereinafter referred to as “ **Service**”).
Our Privacy Policy governs your visit to [https://documenso.com](https://documenso.com), and explains how we collect, safeguard and disclose information that results from your use of our Service.
We use your data to provide and improve Service. By using Service, you agree to the collection and use of information in accordance with this policy. Unless otherwise defined in this Privacy Policy, the terms used in this Privacy Policy have the same meanings as in our Terms and Conditions.
Our Terms and Conditions (“**Terms**”) govern all use of our Service and together with the Privacy Policy constitutes your agreement with us (“ **agreement**”).
### 2\. Definitions
**SERVICE** means the https://documenso.com website operated by Documenso Inc.
**PERSONAL DATA** means data about a living individual who can be identified from those data (or from those and other information either in our possession or likely to come into our possession).
**USAGE DATA** is data collected automatically either generated by the use of Service or from Service infrastructure itself (for example, the duration of a page visit).
**COOKIES** are small files stored on your device (computer or mobile device).
**DATA CONTROLLER** means a natural or legal person who (either alone or jointly or in common with other persons) determines the purposes for which and the manner in which any personal data are, or are to be, processed. For the purpose of this Privacy Policy, we are a Data Controller of your data.
**DATA PROCESSORS (OR SERVICE PROVIDERS)** means any natural or legal person who processes the data on behalf of the Data Controller. We may use the services of various Service Providers in order to process your data more effectively.
**DATA SUBJECT** is any living individual who is the subject of Personal Data.
**THE USER** is the individual using our Service. The User corresponds to the Data Subject, who is the subject of Personal Data.
### 3\. Information Collection and Use
We collect several different types of information for various purposes to provide and improve our Service to you.
### 4\. Types of Data Collected
**Personal Data**
While using our Service, we may ask you to provide us with certain personally identifiable information that can be used to contact or identify you (“**Personal Data**”). Personally identifiable information may include, but is not limited to:
1. Email address
2. First name and last name
3. Cookies and Usage Data
We may use your Personal Data to contact you with newsletters, marketing or promotional materials and other information that may be of interest to you. You may opt out of receiving any, or all, of these communications from us by following the unsubscribe link.
**Usage Data**
We may also collect information that your browser sends whenever you visit our Service or when you access Service by or through a mobile device (“**Usage Data**”).
This Usage Data may include information such as your computer's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that you visit, the time and date of your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
When you access Service with a mobile device, this Usage Data may include information such as the type of mobile device you use, your mobile device unique ID, the IP address of your mobile device, your mobile operating system, the type of mobile Internet browser you use, unique device identifiers and other diagnostic data.
**Tracking Cookies Data**
We use cookies and similar tracking technologies to track the activity on our Service and we hold certain information.
Cookies are files with a small amount of data which may include an anonymous unique identifier. Cookies are sent to your browser from a website and stored on your device. Other tracking technologies are also used such as beacons, tags and scripts to collect and track information and to improve and analyze our Service.
You can instruct your browser to refuse all cookies or to indicate when a cookie is being sent. However, if you do not accept cookies, you may not be able to use some portions of our Service.
Examples of Cookies we use:
1. **Session Cookies:** We use Session Cookies to operate our Service.
2. **Preference Cookies:** We use Preference Cookies to remember your preferences and various settings.
3. **Security Cookies:** We use Security Cookies for security purposes.
4. **Advertising Cookies:** Advertising Cookies are used to serve you with advertisements that may be relevant to you and your interests.
### 5\. Use of Data
Documenso Inc. uses the collected data for various purposes:
1. to provide and maintain our Service;
2. to notify you about changes to our Service;
3. to allow you to participate in interactive features of our Service when you choose to do so;
4. to provide customer support;
5. to gather analysis or valuable information so that we can improve our Service;
6. to monitor the usage of our Service;
7. to detect, prevent and address technical issues;
8. to fulfill any other purpose for which you provide it;
9. to carry out our obligations and enforce our rights arising from any contracts entered into between you and us, including for billing and collection;
10. to provide you with notices about your account and/or subscription, including expiration and renewal notices, email-instructions, etc.;
11. to provide you with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless you have opted not to receive such information;
12. in any other way we may describe when you provide the information;
13. for any other purpose with your consent.
### 6\. Retention of Data
We will retain your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.
We will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period, except when this data is used to strengthen the security or to improve the functionality of our Service, or we are legally obligated to retain this data for longer time periods.
### 7\. Transfer of Data
Your information, including Personal Data, may be transferred to and maintained on computers located outside of your state, province, country or other governmental jurisdiction where the data protection laws may differ from those of your jurisdiction.
If you are located outside United States and choose to provide information to us, please note that we transfer the data, including Personal Data, to United States and process it there.
Your consent to this Privacy Policy followed by your submission of such information represents your agreement to that transfer.
Documenso Inc. will take all the steps reasonably necessary to ensure that your data is treated securely and in accordance with this Privacy Policy and no transfer of your Personal Data will take place to an organisation or a country unless there are adequate controls in place including the security of your data and other personal information.
### 8\. Disclosure of Data
We may disclose personal information that we collect, or you provide:
1. **Disclosure for Law Enforcement.**
2. Under certain circumstances, we may be required to disclose your Personal Data if required to do so by law or in response to valid requests by public authorities.
3. **Business Transaction.**
4. If we or our subsidiaries are involved in a merger, acquisition or asset sale, your Personal Data may be transferred.
5. **Other cases. We may disclose your information also:**
1. to our subsidiaries and affiliates;
2. to contractors, service providers, and other third parties we use to support our business;
3. to fulfill the purpose for which you provide it;
### 9\. Security of Data
The security of your data is important to us but remember that no method of transmission over the Internet or method of electronic storage is 100% secure. While we strive to use commercially acceptable means to protect your Personal Data, we cannot guarantee its absolute security.
### 10\. Your Data Protection Rights Under General Data Protection Regulation (GDPR)
If you are a resident of the European Union (EU) and European Economic Area (EEA), you have certain data protection rights, covered by GDPR. See more at [https://eur-lex.europa.eu/eli/reg/2016/679/oj](https://eur-lex.europa.eu/eli/reg/2016/679/oj)
We aim to take reasonable steps to allow you to correct, amend, delete, or limit the use of your Personal Data.
If you wish to be informed what Personal Data we hold about you and if you want it to be removed from our systems, please email us at hi@documenso.com.
In certain circumstances, you have the following data protection rights:
1. the right to access, update or to delete the information we have on you;
2. the right of rectification. You have the right to have your information rectified if that information is inaccurate or incomplete;
3. the right to object. You have the right to object to our processing of your Personal Data;
4. the right of restriction. You have the right to request that we restrict the processing of your personal information;
5. the right to data portability. You have the right to be provided with a copy of your Personal Data in a structured, machine-readable and commonly used format;
6. the right to withdraw consent. You also have the right to withdraw your consent at any time where we rely on your consent to process your personal information;
Please note that we may ask you to verify your identity before responding to such requests. Please note, we may not able to provide Service without some necessary data.
You have the right to complain to a Data Protection Authority about our collection and use of your Personal Data. For more information, please contact your local data protection authority in the European Economic Area (EEA).
### 11\. Your Data Protection Rights under the California Privacy Protection Act (CalOPPA)
CalOPPA is the first state law in the nation to require commercial websites and online services to post a privacy policy. The laws reach stretches well beyond California to require a person or company in the United States (and conceivable the world) that operates websites collecting personally identifiable information from California consumers to post a conspicuous privacy policy on its website stating exactly the information being collected and those individuals with whom it is being shared, and to comply with this policy. See more at: [https://consumercal.org/about-cfc/cfc-education-foundation/california-online-privacy-protection-act-caloppa-3/](https://consumercal.org/about-cfc/cfc-education-foundation/california-online-privacy-protection-act-caloppa-3/)
According to CalOPPA we agree to the following:
1. users can visit our site anonymously;
2. our Privacy Policy link includes the word “Privacy”, and can easily be found on the page specified above on the home page of our website;
3. users will be notified of any privacy policy changes on our Privacy Policy Page;
4. users are able to change their personal information by emailing us at hi@documenso.com.
Our Policy on “Do Not Track” Signals:
We honor Do Not Track signals and do not track, plant cookies, or use advertising when a Do Not Track browser mechanism is in place. Do Not Track is a preference you can set in your web browser to inform websites that you do not want to be tracked.
You can enable or disable Do Not Track by visiting the Preferences or Settings page of your web browser.
### 12\. Your Data Protection Rights under the California Consumer Privacy Act (CCPA)
If you are a California resident, you are entitled to learn what data we collect about you, ask to delete your data and not to sell (share) it. To exercise your data protection rights, you can make certain requests and ask us:
1. **What personal information we have about you**. If you make this request, we will return to you:
1. The categories of personal information we have collected about you.
2. The categories of sources from which we collect your personal information.
3. The business or commercial purpose for collecting or selling your personal information.
4. The categories of third parties with whom we share personal information.
5. The specific pieces of personal information we have collected about you.
6. A list of categories of personal information that we have sold, along with the category of any other company we sold it to. If we have not sold your personal information, we will inform you of that fact.
7. A list of categories of personal information that we have disclosed for a business purpose, along with the category of any other company we shared it with.
Please note, you are entitled to ask us to provide you with this information up to two times in a rolling twelve-month period. When you make this request, the information provided may be limited to the personal information we collected about you in the previous 12 months.
2. **To delete your personal information**. If you make this request, we will delete the personal information we hold about you as of the date of your request from our records and direct any service providers to do the same. In some cases, deletion may be accomplished through de-identification of the information. If you choose to delete your personal information, you may not be able to use certain functions that require your personal information to operate.
3. **To stop selling your personal information**. We don't sell or rent your personal information to any third parties for any purpose. You are the only owner of your Personal Data and can request disclosure or deletion at any time.
Please note, if you ask us to delete or stop selling your data, it may impact your experience with us, and you may not be able to participate in certain programs or membership services which require the usage of your personal information to function. But in no circumstances, we will discriminate against you for exercising your rights.
To exercise your California data protection rights described above, please send your request(s) by one of the following means:
By email: hi@documenso.com
Your data protection rights, described above, are covered by the CCPA, short for the California Consumer Privacy Act. To find out more, visit the official [California Legislative Information website](https://leginfo.legislature.ca.gov/faces/billTextClient.xhtml?bill_id=201720180AB375). The CCPA took effect on 01/01/2020.
### 13\. Service Providers
We may employ third party companies and individuals to facilitate our Service (“ **Service Providers**”), provide Service on our behalf, perform Service-related services or assist us in analysing how our Service is used.
These third parties have access to your Personal Data only to perform these tasks on our behalf and are obligated not to disclose or use it for any other purpose.
### 14\. Analytics
We may use third-party Service Providers to monitor and analyze the use of our Service.
**Plausible Analytics**
Plausible Analytics is an analytics service provided by Conva Ventures Inc. You can find their Privacy Policy here: [https://plausible.io/privacy](https://plausible.io/privacy)
### 15\. CI/CD tools
We may use third-party Service Providers to automate the development process of our Service.
**GitHub**
GitHub is provided by GitHub, Inc.
GitHub is a development platform to host and review code, manage projects, and build software.
For more information on what data GitHub collects for what purpose and how the protection of the data is ensured, please visit GitHub Privacy Policy page: [https://help.github.com/en/articles/github-privacy-statement](https://help.github.com/en/articles/github-privacy-statement) .
### 16\. Payments
We may provide paid products and/or services within Service. In that case, we use third-party services for payment processing (e.g. payment processors).
We will not store or collect your payment card details. That information is provided directly to our third-party payment processors whose use of your personal information is governed by their Privacy Policy. These payment processors adhere to the standards set by PCI-DSS as managed by the PCI Security Standards Council, which is a joint effort of brands like Visa, Mastercard, American Express and Discover. PCI-DSS requirements help ensure the secure handling of payment information.
The payment processors we work with are:
**Stripe:**
Their Privacy Policy can be viewed at: [https://stripe.com/us/privacy](https://stripe.com/us/privacy)
### 17\. Links to Other Sites
Our Service may contain links to other sites that are not operated by us. If you click a third party link, you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit.
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
### 18\. Children's Privacy
Our Services are not intended for use by children under the age of 18 (“ **Child**” or “**Children**”).
We do not knowingly collect personally identifiable information from Children under 18. If you become aware that a Child has provided us with Personal Data, please contact us. If we become aware that we have collected Personal Data from Children without verification of parental consent, we take steps to remove that information from our servers.
### 19\. Changes to This Privacy Policy
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
We will let you know via email and/or a prominent notice on our Service, prior to the change becoming effective and update “effective date” at the top of this Privacy Policy.
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.
### 20\. Contact Us
If you have any questions about this Privacy Policy, please contact us:
By email: hi@documenso.com.

View File

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs');
const path = require('path');
const { withContentlayer } = require('next-contentlayer');
@ -10,15 +11,31 @@ ENV_FILES.forEach((file) => {
});
});
// !: This is a temp hack to get caveat working without placing it back in the public directory.
// !: By inlining this at build time we should be able to sign faster.
const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
experimental: {
serverActionsBodySizeLimit: '10mb',
outputFileTracingRoot: path.join(__dirname, '../../'),
serverActions: {
bodySizeLimit: '50mb',
},
},
reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
transpilePackages: [
'@documenso/assets',
'@documenso/lib',
'@documenso/tailwind-config',
'@documenso/trpc',
'@documenso/ui',
],
env: {
NEXT_PUBLIC_PROJECT: 'marketing',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "0.1.0",
"version": "1.2.3",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -8,10 +8,12 @@
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/assets": "*",
"@documenso/lib": "*",
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
@ -22,8 +24,8 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.0.0",
"next-auth": "4.24.3",
"next": "14.0.3",
"next-auth": "4.24.5",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
@ -42,5 +44,13 @@
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"
},
"overrides": {
"next-auth": {
"next": "$next"
},
"next-contentlayer": {
"next": "$next"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -4,13 +4,13 @@ import { allBlogPosts } from 'contentlayer/generated';
export const runtime = 'edge';
export const size = {
export const contentType = 'image/png';
export const IMAGE_SIZE = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
type BlogPostOpenGraphImageProps = {
params: { post: string };
};
@ -25,16 +25,16 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
// to a constant will break og image generation.
const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([
fetch(new URL('./../../../../assets/inter-bold.ttf', import.meta.url)).then(async (res) =>
fetch(new URL('@documenso/assets/fonts/inter-bold.ttf', import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL('./../../../../assets/inter-regular.ttf', import.meta.url)).then(async (res) =>
fetch(new URL('@documenso/assets/fonts/inter-regular.ttf', import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL('./../../../../assets/background-blog-og.png', import.meta.url)).then(
fetch(new URL('@documenso/assets/images/background-blog-og.png', import.meta.url)).then(
async (res) => res.arrayBuffer(),
),
fetch(new URL('./../../../../../public/logo.png', import.meta.url)).then(async (res) =>
fetch(new URL('@documenso/assets/logo.png', import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
]);
@ -56,7 +56,7 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
</div>
),
{
...size,
...IMAGE_SIZE,
fonts: [
{
name: 'Inter',

View File

@ -29,7 +29,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
return (
<div
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
})}
>
@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
<div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div>
<Footer className="bg-background border-muted mt-24 border-t" />
</div>

View File

@ -18,6 +18,14 @@ export const revalidate = 3600;
export const dynamic = 'force-dynamic';
const GITHUB_HEADERS: Record<string, string> = {
accept: 'application/vnd.github.v3+json',
};
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
}
const ZGithubStatsResponse = z.object({
stargazers_count: z.number(),
forks_count: z.number(),
@ -28,6 +36,10 @@ const ZMergedPullRequestsResponse = z.object({
total_count: z.number(),
});
const ZOpenIssuesResponse = z.object({
total_count: z.number(),
});
const ZStargazersLiveResponse = z.record(
z.object({
stars: z.number(),
@ -48,49 +60,76 @@ const ZEarlyAdoptersResponse = z.record(
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
export default async function OpenPage() {
const GITHUB_HEADERS: Record<string, string> = {
accept: 'application/vnd.github.v3+json',
};
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
}
const {
forks_count: forksCount,
open_issues: openIssues,
stargazers_count: stargazersCount,
} = await fetch('https://api.github.com/repos/documenso/documenso', {
headers: GITHUB_HEADERS,
const fetchGithubStats = async () => {
return await fetch('https://api.github.com/repos/documenso/documenso', {
headers: {
...GITHUB_HEADERS,
},
})
.then(async (res) => res.json())
.then((res) => ZGithubStatsResponse.parse(res));
};
const { total_count: mergedPullRequests } = await fetch(
const fetchOpenIssues = async () => {
return await fetch(
'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1',
{
headers: {
...GITHUB_HEADERS,
},
},
)
.then(async (res) => res.json())
.then((res) => ZOpenIssuesResponse.parse(res));
};
const fetchMergedPullRequests = async () => {
return await fetch(
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
{
headers: GITHUB_HEADERS,
headers: {
...GITHUB_HEADERS,
},
},
)
.then(async (res) => res.json())
.then((res) => ZMergedPullRequestsResponse.parse(res));
};
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
const fetchStargazers = async () => {
return await fetch('https://stargrazer-live.onrender.com/api/stats', {
headers: {
accept: 'application/json',
},
})
.then(async (res) => res.json())
.then((res) => ZStargazersLiveResponse.parse(res));
};
const EARLY_ADOPTERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
const fetchEarlyAdopters = async () => {
return await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
headers: {
accept: 'application/json',
},
})
.then(async (res) => res.json())
.then((res) => ZEarlyAdoptersResponse.parse(res));
};
export default async function OpenPage() {
const [
{ forks_count: forksCount, stargazers_count: stargazersCount },
{ total_count: openIssues },
{ total_count: mergedPullRequests },
STARGAZERS_DATA,
EARLY_ADOPTERS_DATA,
] = await Promise.all([
fetchGithubStats(),
fetchOpenIssues(),
fetchMergedPullRequests(),
fetchStargazers(),
fetchEarlyAdopters(),
]);
const MONTHLY_USERS = await getUserMonthlyGrowth();

View File

@ -2,7 +2,7 @@ import Image from 'next/image';
import { z } from 'zod';
import backgroundPattern from '~/assets/background-pattern.png';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { OSSFriendsContainer } from './container';
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';

View File

@ -1,3 +1,5 @@
'use client';
import Link from 'next/link';
import {

View File

@ -8,24 +8,23 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
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 { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
type SinglePlayerModeStep = 'fields' | 'sign';
const SinglePlayerModeSteps = ['fields', 'sign'] as const;
type SinglePlayerModeStep = (typeof SinglePlayerModeSteps)[number];
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
@ -41,6 +40,9 @@ export const SinglePlayerClient = () => {
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]);
const { mutateAsync: createSinglePlayerDocument } =
trpc.singleplayer.createSinglePlayerDocument.useMutation();
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',
@ -84,6 +86,7 @@ export const SinglePlayerClient = () => {
data.fields.map((field, i) => ({
id: i,
documentId: -1,
templateId: null,
recipientId: -1,
type: field.type,
page: field.pageNumber,
@ -146,6 +149,7 @@ export const SinglePlayerClient = () => {
const placeholderRecipient: Recipient = {
id: -1,
documentId: -1,
templateId: null,
email: '',
name: '',
token: '',
@ -223,37 +227,35 @@ export const SinglePlayerClient = () => {
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
{/* Add fields to PDF page. */}
{step === 'fields' && (
<DocumentFlowFormContainer
className="top-24 lg:h-[calc(100vh-7rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
>
{/* Add fields to PDF page. */}
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
)}
{/* Enter user details and signature. */}
{step === 'sign' && (
{/* Enter user details and signature. */}
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
)}
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>

View File

@ -7,11 +7,10 @@ import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
export default function NotFound() {
const router = useRouter();

View File

@ -2,14 +2,13 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardBeautifulFigure from '@documenso/assets/images/card-beautiful-figure.png';
import cardFastFigure from '@documenso/assets/images/card-fast-figure.png';
import cardSmartFigure from '@documenso/assets/images/card-smart-figure.png';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardBeautifulFigure from '~/assets/card-beautiful-figure.png';
import cardFastFigure from '~/assets/card-fast-figure.png';
import cardSmartFigure from '~/assets/card-smart-figure.png';
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
export const FasterSmarterBeautifulBento = ({

View File

@ -1,17 +1,17 @@
'use client';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
@ -26,23 +26,23 @@ const FOOTER_LINKS = [
{ href: '/singleplayer', text: 'Singleplayer' },
{ href: '/blog', text: 'Blog' },
{ href: '/design-system', text: 'Design' },
{ href: '/open', text: 'Open' },
{ href: '/open', text: 'Open Startup' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
{ href: '/oss-friends', text: 'OSS Friends' },
{ href: '/careers', text: 'Careers' },
{ href: '/privacy', text: 'Privacy' },
];
export const Footer = ({ className, ...props }: FooterProps) => {
const { setTheme } = useTheme();
return (
<div className={cn('border-t py-12', className)} {...props}>
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
<div>
<Link href="/">
<Image
src="/logo.png"
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
@ -77,21 +77,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
))}
</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24">
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
<Sun className="h-5 w-5" />
<span className="sr-only">Light</span>
</button>
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
<Moon className="h-5 w-5" />
<span className="sr-only">Dark</span>
</button>
<div className="flex flex-wrap">
<ThemeSwitcher />
</div>
</div>
</div>

View File

@ -1,10 +1,12 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@ -25,7 +27,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<div className="flex items-center space-x-4">
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
<Image
src="/logo.png"
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
@ -62,7 +64,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
href="/open"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Open
Open Startup
</Link>
<Link

View File

@ -8,12 +8,11 @@ import { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu';
import { match } from 'ts-pattern';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
import { Widget } from './widget';
export type HeroProps = {

View File

@ -8,6 +8,7 @@ import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
export type MobileNavigationProps = {
@ -30,7 +31,7 @@ export const MENU_NAVIGATION_LINKS = [
},
{
href: '/open',
text: 'Open',
text: 'Open Startup',
},
{
href: 'https://status.documenso.com',
@ -63,7 +64,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
<SheetContent className="w-full max-w-[400px]">
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
<Image
src="/logo.png"
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}

View File

@ -2,14 +2,13 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardBuildFigure from '@documenso/assets/images/card-build-figure.png';
import cardOpenFigure from '@documenso/assets/images/card-open-figure.png';
import cardTemplateFigure from '@documenso/assets/images/card-template-figure.png';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardBuildFigure from '~/assets/card-build-figure.png';
import cardOpenFigure from '~/assets/card-open-figure.png';
import cardTemplateFigure from '~/assets/card-template-figure.png';
export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>;
export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => {

View File

@ -2,15 +2,14 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardConnectionsFigure from '@documenso/assets/images/card-connections-figure.png';
import cardPaidFigure from '@documenso/assets/images/card-paid-figure.png';
import cardSharingFigure from '@documenso/assets/images/card-sharing-figure.png';
import cardWidgetFigure from '@documenso/assets/images/card-widget-figure.png';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardConnectionsFigure from '~/assets/card-connections-figure.png';
import cardPaidFigure from '~/assets/card-paid-figure.png';
import cardSharingFigure from '~/assets/card-sharing-figure.png';
import cardWidgetFigure from '~/assets/card-widget-figure.png';
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
export const ShareConnectPaidWidgetBento = ({

View File

@ -1,233 +0,0 @@
'use server';
import { createElement } from 'react';
import { DateTime } from 'luxon';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
const ZCreateSinglePlayerDocumentSchema = z.object({
documentData: z.object({
data: z.string(),
type: z.nativeEnum(DocumentDataType),
}),
documentName: z.string(),
signer: z.object({
email: z.string().email().min(1),
name: z.string(),
signature: z.string(),
}),
fields: z.array(
z.object({
page: z.number(),
type: z.nativeEnum(FieldType),
positionX: z.number(),
positionY: z.number(),
width: z.number(),
height: z.number(),
}),
),
});
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
/**
* Create and self signs a document.
*
* Returns the document token.
*/
export const createSinglePlayerDocument = async (
value: TCreateSinglePlayerDocumentSchema,
): Promise<string> => {
const { signer, fields, documentData, documentName } =
ZCreateSinglePlayerDocumentSchema.parse(value);
const document = await getFile({
data: documentData.data,
type: documentData.type,
});
const doc = await PDFDocument.load(document);
const createdAt = new Date();
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
const typedSignature = !isBase64 ? signer.signature : null;
// Update the document with the fields inserted.
for (const field of fields) {
const isSignatureField = field.type === FieldType.SIGNATURE;
await insertFieldInPDF(doc, {
...mapField(field, signer),
Signature: isSignatureField
? {
created: createdAt,
signatureImageAsBase64,
typedSignature,
// Dummy data.
id: -1,
recipientId: -1,
fieldId: -1,
}
: null,
// Dummy data.
id: -1,
documentId: -1,
recipientId: -1,
});
}
const unsignedPdfBytes = await doc.save();
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
const { token } = await prisma.$transaction(
async (tx) => {
const token = alphaid();
// Fetch service user who will be the owner of the document.
const serviceUser = await tx.user.findFirstOrThrow({
where: {
email: SERVICE_USER_EMAIL,
},
});
const { id: documentDataId } = await putFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
});
// Create document.
const document = await tx.document.create({
data: {
title: documentName,
status: DocumentStatus.COMPLETED,
documentDataId,
userId: serviceUser.id,
createdAt,
},
});
// Create recipient.
const recipient = await tx.recipient.create({
data: {
documentId: document.id,
name: signer.name,
email: signer.email,
token,
signedAt: createdAt,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
});
// Create fields and signatures.
await Promise.all(
fields.map(async (field) => {
const insertedField = await tx.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
...mapField(field, signer),
},
});
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await tx.signature.create({
data: {
fieldId: insertedField.id,
signatureImageAsBase64,
typedSignature,
recipientId: recipient.id,
},
});
}
}),
);
return { document, token };
},
{
maxWait: 5000,
timeout: 30000,
},
);
const template = createElement(DocumentSelfSignedEmailTemplate, {
documentName: documentName,
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
});
// Send email to signer.
await mailer.sendMail({
to: {
address: signer.email,
name: signer.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document signed',
html: render(template),
text: render(template, { plainText: true }),
attachments: [{ content: signedPdfBuffer, filename: documentName }],
});
return token;
};
/**
* Map the fields provided by the user to fields compatible with Prisma.
*
* Signature fields are handled separately.
*
* @param field The field passed in by the user.
* @param signer The details of the person who is signing this document.
* @returns A field compatible with Prisma.
*/
const mapField = (
field: TCreateSinglePlayerDocumentSchema['fields'][number],
signer: TCreateSinglePlayerDocumentSchema['signer'],
) => {
const customText = match(field.type)
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
.with(FieldType.EMAIL, () => signer.email)
.with(FieldType.NAME, () => signer.name)
.otherwise(() => '');
return {
type: field.type,
page: field.page,
positionX: new Prisma.Decimal(field.positionX),
positionY: new Prisma.Decimal(field.positionY),
width: new Prisma.Decimal(field.width),
height: new Prisma.Decimal(field.height),
customText,
inserted: true,
};
};

View File

@ -4,9 +4,11 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { DocumentStatus, Signature } from '@documenso/prisma/client';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import type { Signature } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -14,7 +16,6 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import signingCelebration from '~/assets/signing-celebration.png';
import { ConfettiScreen } from '~/components/(marketing)/confetti-screen';
interface SinglePlayerModeSuccessProps {

View File

@ -1,7 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
@ -88,7 +87,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const now = new Date();
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const bytes64 = await fetch(
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
)
.then(async (res) => res.arrayBuffer())
.then((buffer) => Buffer.from(buffer).toString('base64'));
const { id: documentDataId } = await prisma.documentData.create({
data: {

View File

@ -2,6 +2,15 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
};
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }),

View File

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs');
const path = require('path');
const { version } = require('./package.json');
@ -10,22 +11,35 @@ ENV_FILES.forEach((file) => {
});
});
// !: This is a temp hack to get caveat working without placing it back in the public directory.
// !: By inlining this at build time we should be able to sign faster.
const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: {
serverActionsBodySizeLimit: '50mb',
outputFileTracingRoot: path.join(__dirname, '../../'),
serverActions: {
bodySizeLimit: '50mb',
},
},
reactStrictMode: true,
transpilePackages: [
'@documenso/assets',
'@documenso/ee',
'@documenso/lib',
'@documenso/prisma',
'@documenso/tailwind-config',
'@documenso/trpc',
'@documenso/ui',
'@documenso/email',
],
env: {
APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "0.1.0",
"version": "1.2.3",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -8,10 +8,13 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"e2e:prepare": "next build && next start",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/assets": "*",
"@documenso/ee": "*",
"@documenso/lib": "*",
"@documenso/prisma": "*",
@ -25,8 +28,8 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.0.0",
"next-auth": "4.24.3",
"next": "14.0.3",
"next-auth": "4.24.5",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"perfect-freehand": "^1.2.0",
@ -42,6 +45,7 @@
"sharp": "0.32.5",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
"uqr": "^0.1.2",
"zod": "^3.22.4"
},
"devDependencies": {
@ -50,5 +54,13 @@
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"
},
"overrides": {
"next-auth": {
"next": "$next"
},
"next-contentlayer": {
"next": "$next"
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@ -2,7 +2,7 @@ import React from 'react';
import { redirect } from 'next/navigation';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { AdminNav } from './nav';

View File

@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';

View File

@ -1,8 +1,16 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
export async function search(search: string, page: number, perPage: number) {
const { user } = await getRequiredServerComponentSession();
if (!isAdmin(user)) {
throw new Error('Unauthorized');
}
const results = await findUsers({ username: search, email: search, page, perPage });
return results;

View File

@ -4,28 +4,26 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
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 { addFields } from '~/components/forms/edit-document/add-fields.action';
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
export type EditDocumentFormProps = {
className?: string;
user: User;
@ -35,7 +33,8 @@ export type EditDocumentFormProps = {
documentData: DocumentData;
};
type EditDocumentStep = 'signers' | 'fields' | 'subject';
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
export const EditDocumentForm = ({
className,
@ -48,29 +47,60 @@ export const EditDocumentForm = ({
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditDocumentStep>('signers');
// controlled stepper state
const [step, setStep] = useState<EditDocumentStep>(
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
);
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
title: {
title: 'Add Title',
description: 'Add the title to the document.',
stepIndex: 1,
},
signers: {
title: 'Add Signers',
description: 'Add the people who will sign the document.',
stepIndex: 1,
stepIndex: 2,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
onBackStep: () => setStep('signers'),
stepIndex: 3,
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
stepIndex: 3,
onBackStep: () => setStep('fields'),
stepIndex: 4,
},
};
const currentDocumentFlow = documentFlow[step];
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
try {
// Custom invocation server action
await addTitle({
documentId: document.id,
title: data.title,
});
router.refresh();
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating title.',
variant: 'destructive',
});
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
@ -81,7 +111,6 @@ export const EditDocumentForm = ({
});
router.refresh();
setStep('fields');
} catch (err) {
console.error(err);
@ -103,7 +132,6 @@ export const EditDocumentForm = ({
});
router.refresh();
setStep('subject');
} catch (err) {
console.error(err);
@ -120,7 +148,7 @@ export const EditDocumentForm = ({
const { subject, message } = data.email;
try {
await completeDocument({
await sendDocument({
documentId: document.id,
email: {
subject,
@ -146,6 +174,8 @@ export const EditDocumentForm = ({
}
};
const currentDocumentFlow = documentFlow[step];
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
@ -158,44 +188,47 @@ export const EditDocumentForm = ({
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
>
<AddTitleFormPartial
key={recipients.length}
documentFlow={documentFlow.title}
recipients={recipients}
fields={fields}
document={document}
onSubmit={onAddTitleFormSubmit}
/>
{step === 'signers' && (
<AddSignersFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
document={document}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSignersFormSubmit}
/>
)}
{step === 'fields' && (
<AddFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddFieldsFormSubmit}
/>
)}
{step === 'subject' && (
<AddSubjectFormPartial
key={recipients.length}
documentFlow={documentFlow.subject}
document={document}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSubjectFormSubmit}
/>
)}
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>

View File

@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
@ -43,11 +43,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
const { documentData } = document;
const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({
getRecipientsForDocument({
documentId,
userId: user.id,
}),
await getFieldsForDocument({
getFieldsForDocument({
documentId,
userId: user.id,
}),

View File

@ -0,0 +1,189 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { History } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
document: Document;
recipients: Recipient[];
};
export const ZResendDocumentFormSchema = z.object({
recipients: z.array(z.number()).min(1, {
message: 'You must select at least one item.',
}),
});
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const ResendDocumentActionItem = ({
document,
recipients,
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
const isDisabled =
!isOwner ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
defaultValues: {
recipients: [],
},
});
const {
handleSubmit,
formState: { isSubmitting },
} = form;
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
toast({
title: 'Document re-sent',
description: 'Your document has been re-sent successfully.',
duration: 5000,
});
setIsOpen(false);
} catch (err) {
toast({
title: 'Something went wrong',
description: 'This document could not be re-sent at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -2,13 +2,17 @@
import Link from 'next/link';
import { Edit, Pencil, Share } from 'lucide-react';
import { Download, Edit, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DataTableActionButtonProps = {
row: Document & {
@ -19,6 +23,7 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
if (!session) {
return null;
@ -33,6 +38,50 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = async () => {
try {
let document: DocumentWithData | null = null;
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (error) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to download file.',
variant: 'destructive',
});
}
};
return match({
isOwner,
isRecipient,
@ -42,7 +91,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
isSigned,
})
.with({ isOwner: true, isDraft: true }, () => (
<Button className="w-24" asChild>
<Button className="w-32" asChild>
<Link href={`/documents/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
@ -50,23 +99,24 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
</Button>
))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-24" asChild>
<Button className="w-32" asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</Link>
</Button>
))
.otherwise(() => (
<DocumentShareButton
documentId={row.id}
token={recipient?.token}
trigger={({ loading }) => (
<Button className="w-24" loading={loading}>
{!loading && <Share className="-ml-1 mr-2 h-4 w-4" />}
Share
</Button>
)}
/>
));
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
Sign
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-32" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
</Button>
))
.otherwise(() => <div></div>);
};

View File

@ -8,7 +8,6 @@ import {
Copy,
Download,
Edit,
History,
Loader,
MoreHorizontal,
Pencil,
@ -19,8 +18,9 @@ import {
import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
@ -31,7 +31,8 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDocumentDialog } from './delete-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
export type DataTableActionDropdownProps = {
@ -59,7 +60,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
// const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
const isDocumentDeletable = isOwner;
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
@ -87,15 +88,18 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = row.title || 'document.pdf';
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
@ -124,7 +128,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Download
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<DropdownMenuItem disabled>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
@ -141,19 +145,16 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuItem disabled>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
<DocumentShareButton
documentId={row.id}
token={recipient?.token}
token={isOwner ? undefined : recipient?.token}
trigger={({ loading, disabled }) => (
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share
Share Signing Card
</div>
</DropdownMenuItem>
)}
@ -161,8 +162,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
</DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDraftDocumentDialog
<DeleteDocumentDialog
id={row.id}
status={row.status}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>

View File

@ -6,8 +6,9 @@ import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { Document, Recipient, User } from '@documenso/prisma/client';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -74,12 +75,14 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
},
{
header: 'Actions',
cell: ({ row }) => (
<div className="flex items-center gap-x-4">
<DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} />
</div>
),
cell: ({ row }) =>
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} />
</div>
),
},
]}
data={results.data}

View File

@ -0,0 +1,120 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
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 { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDraftDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
};
export const DeleteDocumentDialog = ({
id,
open,
onOpenChange,
status,
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Document deleted',
description: 'Your document has been successfully deleted.',
duration: 5000,
});
onOpenChange(false);
},
});
const onDelete = async () => {
try {
await deleteDocument({ id, status });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === 'delete');
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this document?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be
permanently deleted.
</DialogDescription>
</DialogHeader>
{status !== DocumentStatus.DRAFT && (
<div className="mt-8">
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
/>
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
loading={isLoading}
onClick={onDelete}
disabled={!isDeleteEnabled}
variant="destructive"
className="flex-1"
>
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -26,14 +26,14 @@ export const DuplicateDocumentDialog = ({
const router = useRouter();
const { toast } = useToast();
const { data, isLoading } = trpcReact.document.getDocumentById.useQuery({
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
});
const documentData = data?.documentData
const documentData = document?.documentData
? {
...data.documentData,
data: data.documentData.initialData,
...document.documentData,
data: document.documentData.initialData,
}
: undefined;
@ -78,7 +78,7 @@ export const DuplicateDocumentDialog = ({
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll ">
<LazyPDFViewer key={data?.id} documentData={documentData} />
<LazyPDFViewer key={document?.id} documentData={documentData} />
</div>
)}

View File

@ -1,6 +1,6 @@
import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
@ -8,10 +8,8 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import {
PeriodSelectorValue,
isPeriodSelectorValue,
} from '~/components/(dashboard)/period-selector/types';
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
@ -80,7 +78,12 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />

View File

@ -6,8 +6,10 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
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 { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client';
@ -22,6 +24,8 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
const { toast } = useToast();
@ -53,6 +57,12 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: session?.user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
router.push(`/documents/${id}`);
} catch (error) {
console.error(error);
@ -79,7 +89,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0}
disabled={remaining.documents === 0 || !session?.user.emailVerified}
onDrop={onFileDrop}
/>

View File

@ -6,9 +6,10 @@ import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
@ -30,6 +31,7 @@ export default async function AuthenticatedDashboardLayout({
return (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@ -5,8 +5,9 @@ import {
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export const createBillingPortal = async () => {

View File

@ -7,8 +7,8 @@ import {
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe } from '@documenso/lib/server-only/stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export type CreateCheckoutOptions = {
@ -23,7 +23,7 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {

View File

@ -4,9 +4,9 @@ import { match } from 'ts-pattern';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Stripe } from '@documenso/lib/server-only/stripe';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { LocaleDate } from '~/components/formatter/locale-date';
@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
return (
<div>
<h3 className="text-lg font-medium">Billing</h3>
<h3 className="text-2xl font-semibold">Billing</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (

View File

@ -1,19 +1,5 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { redirect } from 'next/navigation';
import { PasswordForm } from '~/components/forms/password';
export default async function PasswordSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-lg font-medium">Password</h3>
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
<hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" />
</div>
);
export default function PasswordSettingsPage() {
redirect('/settings/security');
}

View File

@ -1,4 +1,4 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { ProfileForm } from '~/components/forms/profile';
@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
return (
<div>
<h3 className="text-lg font-medium">Profile</h3>
<h3 className="text-2xl font-semibold">Profile</h3>
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>

View File

@ -0,0 +1,46 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
import { PasswordForm } from '~/components/forms/password';
export default async function SecuritySettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-2xl font-semibold">Security</h3>
<p className="text-muted-foreground mt-2 text-sm">
Here you can manage your password and security settings.
</p>
<hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" />
<hr className="mb-4 mt-8" />
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
<p className="text-muted-foreground mt-2 text-sm">
Add and manage your two factor security settings to add an extra layer of security to your
account!
</p>
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Two-factor methods</h5>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
{user.twoFactorEnabled && (
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Recovery methods</h5>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,155 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} 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 { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
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 { useToast } from '@documenso/ui/primitives/use-toast';
import { addTemplateFields } from '~/components/forms/edit-template/add-template-fields.action';
import { addTemplatePlaceholders } from '~/components/forms/edit-template/add-template-placeholders.action';
export type EditTemplateFormProps = {
className?: string;
user: User;
template: Template;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
};
type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
export const EditTemplateForm = ({
className,
template,
recipients,
fields,
user: _user,
documentData,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('signers');
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
},
};
const currentDocumentFlow = documentFlow[step];
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await addTemplatePlaceholders({
templateId: template.id,
signers: data.signers,
});
router.refresh();
setStep('fields');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
toast({
title: 'Template saved',
description: 'Your templates has been saved successfully.',
duration: 5000,
});
router.push('/templates');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<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={documentData.id} documentData={documentData} />
</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()}
>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

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

View File

@ -0,0 +1,79 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import type { Template } from '@documenso/prisma/client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
export type DataTableActionDropdownProps = {
row: Template;
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) {
return null;
}
const isOwner = row.userId === session.user.id;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/templates/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
<DuplicateTemplateDialog
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
<DeleteTemplateDialog
id={row.id}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
</DropdownMenu>
);
};

View File

@ -0,0 +1,136 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader, Plus } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { useToast } from '@documenso/ui/primitives/use-toast';
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';
type TemplatesDataTableProps = {
templates: Template[];
perPage: number;
page: number;
totalPages: number;
};
export const TemplatesDataTable = ({
templates,
perPage,
page,
totalPages,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const router = useRouter();
const { toast } = useToast();
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const onUseButtonClick = async (templateId: number) => {
try {
const { id } = await createDocumentFromTemplate({
templateId,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`/documents/${id}`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
}
};
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <TemplateType type={row.original.type} />,
},
{
header: 'Actions',
accessorKey: 'actions',
cell: ({ row }) => {
const isRowLoading = loadingStates[row.original.id];
return (
<div className="flex items-center gap-x-4">
<Button
disabled={isRowLoading}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
await onUseButtonClick(row.original.id);
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
}}
>
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown row={row.original} />
</div>
);
},
},
]}
data={templates}
perPage={perPage}
currentPage={page}
totalPages={totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,26 @@
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { Template } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Template;
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
return (
<Link
href={`/templates/${row.id}`}
className="block max-w-[10rem] cursor-pointer truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
);
};

View File

@ -12,35 +12,30 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDraftDocumentDialogProps = {
type DeleteTemplateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DeleteDraftDocumentDialog = ({
id,
open,
onOpenChange,
}: DeleteDraftDocumentDialogProps) => {
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: deleteDocument, isLoading } =
trpcReact.document.deleteDraftDocument.useMutation({
onSuccess: () => {
router.refresh();
const { mutateAsync: deleteDocument, isLoading } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Document deleted',
description: 'Your document has been successfully deleted.',
duration: 5000,
});
toast({
title: 'Template deleted',
description: 'Your document has been successfully deleted.',
duration: 5000,
});
onOpenChange(false);
},
});
onOpenChange(false);
},
});
const onDraftDelete = async () => {
try {
@ -48,7 +43,7 @@ export const DeleteDraftDocumentDialog = ({
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be deleted at this time. Please try again.',
description: 'This template could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
@ -59,10 +54,10 @@ export const DeleteDraftDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this document?</DialogTitle>
<DialogTitle>Do you want to delete this template?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</DialogDescription>
</DialogHeader>

View File

@ -0,0 +1,89 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
id,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isLoading } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template duplicated',
description: 'Your template has been duplicated successfully.',
duration: 5000,
});
onOpenChange(false);
},
});
const onDuplicate = async () => {
try {
await duplicateTemplate({
templateId: id,
});
router.refresh();
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to duplicate this template?</DialogTitle>
<DialogDescription className="pt-2">Your template will be duplicated.</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
Duplicate
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,17 @@
import { Bird } from 'lucide-react';
export const EmptyTemplateState = () => {
return (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">We're all empty</h3>
<p className="mt-2 max-w-[50ch]">
You have not yet created any templates. To create a template please upload one.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,228 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { FilePlus, X } 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 { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
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 { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
export const NewTemplateDialog = () => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
resolver: zodResolver(ZCreateTemplateFormSchema),
defaultValues: {
name: '',
},
});
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
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) {
return;
}
const file: File = uploadedFile.file;
try {
const { type, data } = await putFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,
data,
});
const { id } = await createTemplate({
title: values.name ? values.name : file.name,
templateDocumentDataId,
});
toast({
title: 'Template document uploaded',
description:
'Your document has been uploaded successfully. You will be redirected to the template page.',
duration: 5000,
});
setShowNewTemplateDialog(false);
void router.push(`/templates/${id}`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const resetForm = () => {
if (form.getValues('name') === uploadedFile?.file.name) {
form.reset();
}
setUploadedFile(null);
};
useEffect(() => {
if (!showNewTemplateDialog) {
form.reset();
}
}, [form, showNewTemplateDialog]);
return (
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
New Template
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl">
<DialogHeader>
<DialogTitle className="mb-4">New Template</DialogTitle>
</DialogHeader>
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name your template</FormLabel>
<FormControl>
<Input id="email" type="text" className="bg-background mt-1.5" {...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>
<Label htmlFor="template">Upload a Document</Label>
<div className="my-3">
{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>
<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="mt-1.5 h-[40vh]"
onDrop={onFileDrop}
type="template"
/>
)}
</div>
</div>
<div className="flex w-full justify-end">
<Button loading={isCreatingTemplate} type="submit">
Create Template
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@ -3,17 +3,16 @@ import { NextResponse } from 'next/server';
import { P, match } from 'ts-pattern';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug';
import type { ShareHandlerAPIResponse } from '~/pages/api/share';
import { Logo } from '~/components/branding/logo';
import { getAssetBuffer } from '~/helpers/get-asset-buffer';
export const runtime = 'edge';
const CARD_OFFSET_TOP = 152;
const CARD_OFFSET_LEFT = 350;
const CARD_WIDTH = 500;
const CARD_HEIGHT = 250;
const CARD_OFFSET_TOP = 173;
const CARD_OFFSET_LEFT = 307;
const CARD_WIDTH = 590;
const CARD_HEIGHT = 337;
const size = {
const IMAGE_SIZE = {
width: 1200,
height: 630,
};
@ -24,15 +23,27 @@ type SharePageOpenGraphImageProps = {
export async function GET(_request: Request, { params: { slug } }: SharePageOpenGraphImageProps) {
const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([
getAssetBuffer('/fonts/inter-semibold.ttf'),
getAssetBuffer('/fonts/inter-regular.ttf'),
getAssetBuffer('/fonts/caveat-regular.ttf'),
getAssetBuffer('/static/og-share-frame.png'),
fetch(new URL('@documenso/assets/fonts/inter-semibold.ttf', import.meta.url)).then(
async (res) => res.arrayBuffer(),
),
fetch(new URL('@documenso/assets/fonts/inter-regular.ttf', import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL('@documenso/assets/fonts/caveat-regular.ttf', import.meta.url)).then(
async (res) => res.arrayBuffer(),
),
fetch(new URL('@documenso/assets/static/og-share-frame2.png', import.meta.url)).then(
async (res) => res.arrayBuffer(),
),
]);
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({ slug }).catch(() => null);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
if (!recipientOrSender) {
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
new URL(`/api/share?slug=${slug}`, baseUrl),
).then(async (res) => res.json());
if ('error' in recipientOrSender) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
@ -60,11 +71,6 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
{/* @ts-expect-error Lack of typing from ImageResponse */}
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
<div tw="absolute top-20 flex w-full items-center justify-center">
{/* @ts-expect-error Lack of typing from ImageResponse */}
<Logo tw="h-8 w-60" />
</div>
{signatureImage ? (
<div
tw="absolute py-6 px-12 flex items-center justify-center text-center"
@ -96,40 +102,28 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
</p>
)}
{/* <div
tw="absolute flex items-center justify-center text-slate-500"
style={{
top: `${CARD_OFFSET_TOP + CARD_HEIGHT - 45}px`,
left: `${CARD_OFFSET_LEFT}`,
width: `${CARD_WIDTH}px`,
fontSize: '30px',
}}
>
{signatureName}
</div> */}
<div
tw="absolute flex flex-col items-center justify-center pt-12 w-full"
tw="absolute flex w-full"
style={{
top: `${CARD_OFFSET_TOP + CARD_HEIGHT}px`,
top: `${CARD_OFFSET_TOP - 78}px`,
left: `${CARD_OFFSET_LEFT}px`,
}}
>
<h2
tw="text-3xl text-slate-500"
tw="text-xl"
style={{
color: '#828282',
fontFamily: 'Inter',
fontWeight: 600,
fontWeight: 700,
}}
>
{isRecipient
? 'I just signed with Documenso and you can too!'
: 'I just sent a document with Documenso and you can too!'}
{isRecipient ? 'Document Signed!' : 'Document Sent!'}
</h2>
</div>
</div>
),
{
...size,
...IMAGE_SIZE,
fonts: [
{
name: 'Caveat',

View File

@ -11,7 +11,7 @@ type SharePageProps = {
export function generateMetadata({ params: { slug } }: SharePageProps) {
return {
title: 'Documenso - Share',
description: 'I just signed a document with Documenso!',
description: 'I just signed a document in style with Documenso!',
openGraph: {
title: 'Documenso - Join the open source signing revolution',
description: 'I just signed with Documenso!',

View File

@ -2,8 +2,10 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8 } from 'lucide-react';
import { getServerSession } from 'next-auth';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@ -13,8 +15,6 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import signingCelebration from '~/assets/signing-celebration.png';
export type CompletedSigningPageProps = {
params: {
token?: string;
@ -54,6 +54,9 @@ export default async function CompletedSigningPage({
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
const sessionData = await getServerSession();
const isLoggedIn = !!sessionData?.user;
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
{/* Card with recipient */}
@ -64,18 +67,24 @@ export default async function CompletedSigningPage({
/>
<div className="relative mt-6 flex w-full flex-col items-center">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
<div className="text-documenso-700 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span>
</div>
))
.otherwise(() => (
.with({ deletedAt: null }, () => (
<div className="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>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document no longer available to sign</span>
</div>
))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
@ -83,16 +92,22 @@ export default async function CompletedSigningPage({
<span className="mt-1.5 block">"{document.title}"</span>
</h2>
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document.
</p>
))
.otherwise(() => (
.with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed.
</p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner and is no longer available for others to
sign.
</p>
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
@ -106,15 +121,21 @@ export default async function CompletedSigningPage({
/>
</div>
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
{isLoggedIn ? (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home
</Link>
</p>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)}
</div>
</div>
);

View File

@ -7,9 +7,10 @@ import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { Document, Field, Recipient } from '@documenso/prisma/client';
import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -19,6 +20,7 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog';
export type SigningFormProps = {
document: Document;
@ -28,12 +30,15 @@ export type SigningFormProps = {
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const {
handleSubmit,
formState: { isSubmitting },
@ -45,6 +50,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fields);
if (!isFieldsValid) {
@ -56,6 +62,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
documentId: document.id,
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
router.push(`/sign/${recipient.token}/complete`);
};
@ -132,9 +144,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
Cancel
</Button>
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
Complete
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
/>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';

View File

@ -0,0 +1,68 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Clock8 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import type { Document, Signature } from '@documenso/prisma/client';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
type NoLongerAvailableProps = {
document: Document;
recipientName: string;
recipientSignature: Signature;
};
export const NoLongerAvailable = ({
document,
recipientName,
recipientSignature,
}: NoLongerAvailableProps) => {
const { data: session } = useSession();
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D
name={recipientName}
signature={recipientSignature}
signingCelebrationImage={signingCelebration}
/>
<div className="relative mt-2 flex w-full flex-col items-center">
<div className="mt-8 flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document Cancelled</span>
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<span className="mt-1.5 block">"{document.title}"</span>
is no longer available to sign
</h2>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner.
</p>
{session?.user ? (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)}
</div>
</div>
);
};

View File

@ -3,11 +3,12 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@ -17,6 +18,7 @@ import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { NoLongerAvailable } from './no-longer-available';
import { SigningProvider } from './provider';
import { SignatureField } from './signature-field';
@ -55,6 +57,18 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
redirect(`/sign/${token}/complete`);
}
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
if (document.deletedAt) {
return (
<NoLongerAvailable
document={document}
recipientName={recipient.name}
recipientSignature={recipientSignature}
/>
);
}
return (
<SigningProvider
email={recipient.email}

View File

@ -0,0 +1,77 @@
import { useState } from 'react';
import { Document, Field } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
export type SignDialogProps = {
isSubmitting: boolean;
document: Document;
fields: Field[];
onSignatureComplete: () => void | Promise<void>;
};
export const SignDialog = ({
isSubmitting,
document,
fields,
onSignatureComplete,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const isComplete = fields.every((field) => field.inserted);
return (
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button
className="w-full"
type="button"
size="lg"
disabled={!isComplete}
loading={isSubmitting}
>
Complete
</Button>
</DialogTrigger>
<DialogContent>
<div className="text-center">
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
You are about to finish signing "{document.title}". Are you sure?
</div>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowDialog(false);
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={onSignatureComplete}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
@ -76,10 +76,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return;
}
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
if (!value) {
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: source === 'local' && localSignature ? localSignature : providedSignature ?? '',
value,
isBase64: true,
});

View File

@ -2,7 +2,7 @@ import React from 'react';
import Image from 'next/image';
import backgroundPattern from '~/assets/background-pattern.png';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
type UnauthenticatedLayoutProps = {
children: React.ReactNode;

View File

@ -0,0 +1,97 @@
import Link from 'next/link';
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { Button } from '@documenso/ui/primitives/button';
export type PageProps = {
params: {
token: string;
};
};
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) {
return (
<div className="w-full">
<div className="mb-4 text-red-300">
<XOctagon />
</div>
<h2 className="text-4xl font-semibold">No token provided</h2>
<p className="text-muted-foreground mt-2 text-base">
It seems that there is no token provided. Please check your email and try again.
</p>
</div>
);
}
const verified = await verifyEmail({ token });
if (verified === null) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
<p className="text-muted-foreground mt-4">
We were unable to verify your email. If your email is not verified already, please try
again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
if (!verified) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
<p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token, please
check your email and try again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
<p className="text-muted-foreground mt-4">
Your email has been successfully confirmed! You can now use all features of Documenso.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

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

View File

@ -12,7 +12,6 @@ import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
import './globals.css';
@ -69,13 +68,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body>
<LocaleProvider locale={locale}>
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
<Toaster />
</FeatureFlagProvider>

View File

@ -1,6 +1,6 @@
import Link from 'next/link';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Button } from '@documenso/ui/primitives/button';
import NotFoundPartial from '~/components/partials/not-found';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

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