mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 12:32:34 +10:00
Compare commits
153 Commits
v1.3.0-rc.
...
feat/accep
| Author | SHA1 | Date | |
|---|---|---|---|
| 536adf6e0a | |||
| be3ab09738 | |||
| 1c4a5449bb | |||
| 144bd4782b | |||
| 857e35c10a | |||
| e9d6c24137 | |||
| dd5f39205a | |||
| f6ce7be61f | |||
| 9979d32a56 | |||
| daa541d570 | |||
| 1d91a9e813 | |||
| 560352492d | |||
| 075e15d428 | |||
| 27e5ef0a51 | |||
| 84b0c2756b | |||
| 58b3a127ea | |||
| 7e71e06e04 | |||
| 0b8e84b6b7 | |||
| e17e4566cd | |||
| d73ef57794 | |||
| b09071ebc7 | |||
| 66bb56047a | |||
| f9d26e6b3f | |||
| 3054d84ba7 | |||
| a71078cbd5 | |||
| 4fd6a0d5b6 | |||
| fface15a22 | |||
| 6be119ac95 | |||
| b8a45dd5e3 | |||
| 5660b99df7 | |||
| 8c5216cd44 | |||
| e646c4cf08 | |||
| 5d56b152d6 | |||
| 5c16b10dc2 | |||
| 9bcd6e39e7 | |||
| 2202fa3d04 | |||
| c1a6a327af | |||
| 837c17f1f3 | |||
| d731532fbf | |||
| d02f6774b2 | |||
| 77facba8b4 | |||
| e900706ab0 | |||
| bed788f78f | |||
| 341481d6db | |||
| 8f5634268d | |||
| 5d6f69dc19 | |||
| 5307fa6453 | |||
| 0bb86963a9 | |||
| fb0d9b8ef9 | |||
| 918c6f19f2 | |||
| 3f89f8725b | |||
| c4800f74b9 | |||
| d8eff192fe | |||
| eb84d7ff3c | |||
| 32633f96d2 | |||
| 27b7e29be7 | |||
| de4a0b2560 | |||
| 5a11de1db9 | |||
| f7cf33c61b | |||
| 8f4fea2f14 | |||
| 5a32b5cafd | |||
| 8d1b960aa8 | |||
| 2b25806c33 | |||
| 6d58e60a65 | |||
| dd56836121 | |||
| e9312ada51 | |||
| 1c52c7ebcd | |||
| 495cd35f7c | |||
| 5a5d00fb2e | |||
| 1aa0fc3101 | |||
| 48cdf43dcb | |||
| 1d9593dd0f | |||
| 9ad94f9862 | |||
| 972c20f906 | |||
| 519c645d06 | |||
| 7babd82470 | |||
| 298396c86c | |||
| 268a5c6508 | |||
| c40c9b20ec | |||
| 84a0c39810 | |||
| 1af909835d | |||
| 01caa949d9 | |||
| 775a1b774d | |||
| c949c4701b | |||
| eda635e2db | |||
| 2056de2e16 | |||
| 075fdd1f88 | |||
| 006a559026 | |||
| ff64671e49 | |||
| 089ba1c30e | |||
| cbcd893cfd | |||
| 83dfe92d7a | |||
| e43e8f8c4a | |||
| 82da337a56 | |||
| 6e10947d00 | |||
| 682cb37786 | |||
| 5809480f02 | |||
| 1eeb5fb103 | |||
| 88534fa1c6 | |||
| 31a9127c9e | |||
| 6d34ebd91b | |||
| f2d4c0721d | |||
| f9139a54a5 | |||
| 2d931b2c9b | |||
| 6ad3edb6c8 | |||
| 5c1d30bfbb | |||
| 95041fa2e4 | |||
| 49736d2587 | |||
| ee5ce78c82 | |||
| 3b3987dcf8 | |||
| 78a1ee2af0 | |||
| dbdef79263 | |||
| 323380d757 | |||
| e4b7747f66 | |||
| 0697e7f817 | |||
| e1d3874e79 | |||
| 497d9140d2 | |||
| 7d22957404 | |||
| 38e5b1d3ce | |||
| 09dcc2cac0 | |||
| d8d36ae8e2 | |||
| dfec8df31e | |||
| bc38009392 | |||
| 5e9bc56329 | |||
| 48f6765e76 | |||
| 7feba02e08 | |||
| 4e799e68ef | |||
| 520522bef7 | |||
| 52e696c90e | |||
| 02d91d9cd4 | |||
| ac529a89fc | |||
| f181099e74 | |||
| 02e96bbd0a | |||
| 36e48e67ee | |||
| d8588b780a | |||
| ef84f5ba98 | |||
| 68120794f8 | |||
| 88dc797423 | |||
| a43be0432b | |||
| 0f11cc0b4b | |||
| f310139a13 | |||
| 369d08ae6e | |||
| a906833657 | |||
| 4733f1e84b | |||
| ab0d38eaf4 | |||
| 6bbeaa084c | |||
| 231a307b89 | |||
| 0b2dce2238 | |||
| 1e29dfd823 | |||
| dc56c2abf2 | |||
| 62809e9506 | |||
| 318dfcafc3 | |||
| 4ff8592e8f |
16
.env.example
16
.env.example
@ -29,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
|
||||
@ -77,16 +77,14 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
|
||||
|
||||
# [[FEATURES]]
|
||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
# OPTIONAL: Defines the host to use for PostHog.
|
||||
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
|
||||
# OPTIONAL: Leave blank to disable billing.
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
|
||||
# This is only required for the marketing site
|
||||
# [[REDIS]]
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
7
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -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:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: "Feature Request"
|
||||
name: 'Feature Request'
|
||||
description: Suggest a new idea or enhancement for this project
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/improvement.yml
vendored
2
.github/ISSUE_TEMPLATE/improvement.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: "General Improvement"
|
||||
name: 'General Improvement'
|
||||
description: Suggest a minor enhancement or improvement for this project
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
32
.github/dependabot.yml
vendored
32
.github/dependabot.yml
vendored
@ -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
21
.github/labeler.yml
vendored
Normal 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/**
|
||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@ -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 }}
|
||||
@ -19,12 +19,12 @@ jobs:
|
||||
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
|
||||
@ -43,10 +43,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Build Docker Image
|
||||
run: ./docker/build.sh
|
||||
|
||||
|
||||
10
.github/workflows/codeql-analysis.yml
vendored
10
.github/workflows/codeql-analysis.yml
vendored
@ -1,11 +1,11 @@
|
||||
name: "CodeQL"
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@ -23,9 +23,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@ -15,12 +15,10 @@ jobs:
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Push to release branch
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
|
||||
git checkout release || git checkout -b release
|
||||
git merge --ff-only main
|
||||
git push origin release
|
||||
|
||||
9
.github/workflows/e2e-tests.yml
vendored
9
.github/workflows/e2e-tests.yml
vendored
@ -1,19 +1,20 @@
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
29
.github/workflows/first-interaction.yml
vendored
Normal file
29
.github/workflows/first-interaction.yml
vendored
Normal 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)
|
||||
63
.github/workflows/issue-assignee-check.yml
vendored
Normal file
63
.github/workflows/issue-assignee-check.yml
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
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: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- 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
21
.github/workflows/issue-opened.yml
vendored
Normal 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
20
.github/workflows/pr-labeler.yml
vendored
Normal 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: ''
|
||||
64
.github/workflows/pr-review-reminder.yml
vendored
Normal file
64
.github/workflows/pr-review-reminder.yml
vendored
Normal file
@ -0,0 +1,64 @@
|
||||
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: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- 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 }}`,
|
||||
}
|
||||
});
|
||||
}
|
||||
45
.github/workflows/semantic-pull-requests.yml
vendored
45
.github/workflows/semantic-pull-requests.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: "Validate PR Name"
|
||||
name: 'Validate PR Name'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@ -9,13 +9,54 @@ on:
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
validate-pr:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR creator's previous activity
|
||||
id: check_activity
|
||||
run: |
|
||||
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
|
||||
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
|
||||
if [ "$ACTIVITY" -eq 0 ]; then
|
||||
echo "::set-output name=is_new::true"
|
||||
else
|
||||
echo "::set-output name=is_new::false"
|
||||
fi
|
||||
|
||||
- name: Count PRs created by user
|
||||
id: count_prs
|
||||
run: |
|
||||
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
|
||||
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
|
||||
echo "::set-output name=pr_count::$PR_COUNT"
|
||||
|
||||
- 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 && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
|
||||
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! 💚🚀
|
||||
|
||||
24
.github/workflows/stale.yml
vendored
Normal file
24
.github/workflows/stale.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
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: 90
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 180
|
||||
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-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,needs triage'
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,7 +1,7 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
|
||||
26
README.md
26
README.md
@ -13,9 +13,9 @@
|
||||
·
|
||||
<a href="https://github.com/documenso/documenso/issues">Issues</a>
|
||||
·
|
||||
<a href="https://github.com/documenso/documenso/milestones">Roadmap</a>
|
||||
<a href="https://documen.so/live">Upcoming Releases</a>
|
||||
·
|
||||
<a href="https://documen.so/launches">Upcoming Launches</a>
|
||||
<a href="https://documen.so/roadmap">Roadmap</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@ -115,10 +115,12 @@ To run Documenso locally, you will need
|
||||
|
||||
Want to get up and running quickly? Follow these steps:
|
||||
|
||||
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
|
||||
|
||||
After forking the repository, clone it to your local device by using the following command:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/documenso/documenso
|
||||
git clone https://github.com/<your-username>/documenso
|
||||
```
|
||||
|
||||
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults.
|
||||
@ -139,21 +141,25 @@ 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
|
||||
|
||||
Follow these steps to setup Documenso on your local machine:
|
||||
|
||||
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
|
||||
|
||||
After forking the repository, clone it to your local device by using the following command:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/documenso/documenso
|
||||
git clone https://github.com/<your-username>/documenso
|
||||
```
|
||||
|
||||
2. Run `npm i` in the root directory
|
||||
@ -278,12 +284,16 @@ WantedBy=multi-user.target
|
||||
|
||||
### Railway
|
||||
|
||||
[](https://railway.app/template/DjrRRX)
|
||||
[](https://railway.app/template/bG6D4p)
|
||||
|
||||
### Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
|
||||
### Koyeb
|
||||
|
||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### I'm not receiving any emails when using the developer quickstart.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Announcing Pre-Seed and Open Metrics
|
||||
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
||||
description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
|
||||
@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
|
||||
|
||||
## Documenso Merch Shop
|
||||
|
||||
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso.
|
||||
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.11.0",
|
||||
"recharts": "^2.7.2",
|
||||
"sharp": "0.32.5",
|
||||
"sharp": "0.33.1",
|
||||
"typescript": "5.2.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
2
apps/marketing/process-env.d.ts
vendored
2
apps/marketing/process-env.d.ts
vendored
@ -6,8 +6,6 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
||||
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type MonthlyNewUsersChartProps = {
|
||||
@ -22,7 +22,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<div className="flex items-center px-4">
|
||||
<h3 className="text-lg font-semibold">Monthly New Users</h3>
|
||||
<h3 className="text-lg font-semibold">New Users</h3>
|
||||
</div>
|
||||
|
||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type MonthlyTotalUsersChartProps = {
|
||||
@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<div className="flex items-center px-4">
|
||||
<h3 className="text-lg font-semibold">Monthly Total Users</h3>
|
||||
<h3 className="text-lg font-semibold">Total Users</h3>
|
||||
</div>
|
||||
|
||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
||||
|
||||
@ -29,10 +29,7 @@ export function OpenPageTooltip() {
|
||||
</svg>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
August and earlier: Active subscribers. September and beyond: Numbers of active
|
||||
subscriptions.
|
||||
</p>
|
||||
<p>Active Subscriptions.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@ -86,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,
|
||||
@ -148,6 +149,7 @@ export const SinglePlayerClient = () => {
|
||||
const placeholderRecipient: Recipient = {
|
||||
id: -1,
|
||||
documentId: -1,
|
||||
templateId: null,
|
||||
email: '',
|
||||
name: '',
|
||||
token: '',
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Info } from 'lucide-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
export const ZClaimPlanDialogFormSchema = z.object({
|
||||
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
|
||||
|
||||
export type ClaimPlanDialogProps = {
|
||||
className?: string;
|
||||
planId: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
||||
const params = useSearchParams();
|
||||
const analytics = useAnalytics();
|
||||
const event = usePlausible();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<TClaimPlanDialogFormSchema>({
|
||||
defaultValues: {
|
||||
name: params?.get('name') ?? '',
|
||||
email: params?.get('email') ?? '',
|
||||
},
|
||||
resolver: zodResolver(ZClaimPlanDialogFormSchema),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
||||
try {
|
||||
const delay = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
|
||||
const [redirectUrl] = await Promise.all([
|
||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
||||
delay,
|
||||
]);
|
||||
|
||||
event('claim-plan-pricing');
|
||||
analytics.capture('Marketing: Claim plan', { planId, email });
|
||||
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error) {
|
||||
event('claim-plan-failed');
|
||||
analytics.capture('Marketing: Claim plan failure', { planId, email });
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: error instanceof Error ? error.message : 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSubmitting && !open) {
|
||||
reset();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Claim your plan</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
We're almost there! Please enter your email address and name to claim your plan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
|
||||
{params?.get('cancelled') === 'true' && (
|
||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Info className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm leading-5 text-yellow-700">
|
||||
You have cancelled the payment process. If you didn't mean to do this, please
|
||||
try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Name</Label>
|
||||
|
||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
||||
|
||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Email</Label>
|
||||
|
||||
<Input type="email" className="mt-2" {...register('email')} />
|
||||
|
||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||
Claim the early adopters Plan (
|
||||
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
||||
? 'Monthly'
|
||||
: 'Yearly'}
|
||||
)
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -39,7 +39,7 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
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>
|
||||
<div className="flex-shrink-0">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src={LogoImage}
|
||||
@ -64,13 +64,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||
{FOOTER_LINKS.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm"
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm"
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
@ -16,14 +16,9 @@ export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
|
||||
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
|
||||
|
||||
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
const params = useSearchParams();
|
||||
const event = usePlausible();
|
||||
|
||||
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
||||
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
||||
? 'YEARLY'
|
||||
: 'MONTHLY',
|
||||
);
|
||||
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY');
|
||||
|
||||
return (
|
||||
<div className={cn('', className)} {...props}>
|
||||
|
||||
@ -6,8 +6,9 @@ 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';
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { HTMLAttributes, KeyboardEvent } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
@ -26,6 +27,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||
|
||||
import { STEP } from '../constants';
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
const ZWidgetFormSchema = z
|
||||
@ -48,13 +50,16 @@ const ZWidgetFormSchema = z
|
||||
|
||||
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
|
||||
|
||||
type StepKeys = keyof typeof STEP;
|
||||
type StepValues = (typeof STEP)[StepKeys];
|
||||
|
||||
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
const { toast } = useToast();
|
||||
const event = usePlausible();
|
||||
|
||||
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
|
||||
const [step, setStep] = useState<StepValues>(STEP.EMAIL);
|
||||
const [showSigningDialog, setShowSigningDialog] = useState(false);
|
||||
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
|
||||
|
||||
@ -81,28 +86,28 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
const signatureText = watch('signatureText');
|
||||
|
||||
const stepsRemaining = useMemo(() => {
|
||||
if (step === 'NAME') {
|
||||
if (step === STEP.NAME) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (step === 'SIGN') {
|
||||
return 1;
|
||||
if (step === STEP.EMAIL) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return 3;
|
||||
return 1;
|
||||
}, [step]);
|
||||
|
||||
const onNextStepClick = () => {
|
||||
if (step === 'EMAIL') {
|
||||
setStep('NAME');
|
||||
if (step === STEP.EMAIL) {
|
||||
setStep(STEP.NAME);
|
||||
|
||||
setTimeout(() => {
|
||||
document.querySelector<HTMLElement>('#name')?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (step === 'NAME') {
|
||||
setStep('SIGN');
|
||||
if (step === STEP.NAME) {
|
||||
setStep(STEP.SIGN);
|
||||
|
||||
setTimeout(() => {
|
||||
document.querySelector<HTMLElement>('#signatureText')?.focus();
|
||||
@ -226,7 +231,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
type="button"
|
||||
className="bg-primary h-full w-14 rounded"
|
||||
disabled={!field.value || !!errors.email?.message}
|
||||
onClick={() => step === 'EMAIL' && onNextStepClick()}
|
||||
onClick={() => step === STEP.EMAIL && onNextStepClick()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
@ -238,7 +243,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
<FormErrorMessage error={errors.email} className="mt-1" />
|
||||
</motion.div>
|
||||
|
||||
{(step === 'NAME' || step === 'SIGN') && (
|
||||
{(step === STEP.NAME || step === STEP.SIGN) && (
|
||||
<motion.div
|
||||
key="name"
|
||||
className="mt-4"
|
||||
@ -350,6 +355,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
id="signatureText"
|
||||
@ -387,10 +393,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
</DialogHeader>
|
||||
|
||||
<DialogDescription>
|
||||
By signing you signal your support of Documenso's mission in a <br></br>
|
||||
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
|
||||
<br></br>You also unlock the option to purchase the early supporter plan including
|
||||
everything we build this year for fixed price.
|
||||
By signing you signal your support of Documenso's mission in a <br />
|
||||
<strong>non-legally binding, but heartfelt way</strong>. <br />
|
||||
<br />
|
||||
You also unlock the option to purchase the early supporter plan including everything we
|
||||
build this year for fixed price.
|
||||
</DialogDescription>
|
||||
|
||||
<SignaturePad
|
||||
|
||||
5
apps/marketing/src/components/constants.ts
Normal file
5
apps/marketing/src/components/constants.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const STEP = {
|
||||
EMAIL: 'EMAIL',
|
||||
NAME: 'NAME',
|
||||
SIGN: 'SIGN',
|
||||
} as const;
|
||||
@ -1,4 +1,4 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { buffer } from 'micro';
|
||||
@ -6,7 +6,8 @@ import { buffer } from 'micro';
|
||||
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@ -17,6 +17,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||
);
|
||||
|
||||
const FONT_DANCING_SCRIPT_BYTES = fs.readFileSync(
|
||||
path.join(__dirname, '../../packages/assets/fonts/dancing-script.ttf'),
|
||||
);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
@ -40,6 +44,7 @@ const config = {
|
||||
APP_VERSION: version,
|
||||
NEXT_PUBLIC_PROJECT: 'web',
|
||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||
FONT_DANCING_SCRIPT_URI: `data:font/ttf;base64,${FONT_DANCING_SCRIPT_BYTES.toString('base64')}`,
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"sharp": "0.32.5",
|
||||
"sharp": "0.33.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
"uqr": "^0.1.2",
|
||||
|
||||
2
apps/web/process-env.d.ts
vendored
2
apps/web/process-env.d.ts
vendored
@ -6,8 +6,6 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
||||
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
|
||||
@ -9,7 +9,6 @@ import type { z } from 'zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -19,6 +18,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||
@ -117,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
<MultiSelectCombobox
|
||||
listValues={roles}
|
||||
onChange={(values: string[]) => onChange(values)}
|
||||
/>
|
||||
|
||||
@ -8,7 +8,7 @@ import { Edit, Loader } from 'lucide-react';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||
import type { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -19,7 +19,7 @@ type UserData = {
|
||||
name: string | null;
|
||||
email: string;
|
||||
roles: Role[];
|
||||
Subscription?: SubscriptionLite | null;
|
||||
Subscription?: SubscriptionLite[] | null;
|
||||
Document: DocumentLite[];
|
||||
};
|
||||
|
||||
@ -35,9 +35,16 @@ type UsersDataTableProps = {
|
||||
totalPages: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
individualPriceIds: string[];
|
||||
};
|
||||
|
||||
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
|
||||
export const UsersDataTable = ({
|
||||
users,
|
||||
totalPages,
|
||||
perPage,
|
||||
page,
|
||||
individualPriceIds,
|
||||
}: UsersDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const [searchString, setSearchString] = useState('');
|
||||
@ -100,7 +107,13 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa
|
||||
{
|
||||
header: 'Subscription',
|
||||
accessorKey: 'subscription',
|
||||
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
|
||||
cell: ({ row }) => {
|
||||
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
|
||||
individualPriceIds.includes(sub.priceId),
|
||||
);
|
||||
|
||||
return foundIndividualSubscription?.status ?? 'NONE';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Documents',
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
||||
|
||||
import { UsersDataTable } from './data-table-users';
|
||||
import { search } from './fetch-users.actions';
|
||||
|
||||
@ -14,12 +16,23 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
const searchString = searchParams.search || '';
|
||||
|
||||
const { users, totalPages } = await search(searchString, page, perPage);
|
||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||
search(searchString, page, perPage),
|
||||
getPricesByType('individual'),
|
||||
]);
|
||||
|
||||
const individualPriceIds = individualPrices.map((price) => price.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Manage users</h2>
|
||||
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
|
||||
<UsersDataTable
|
||||
users={users}
|
||||
individualPriceIds={individualPriceIds}
|
||||
totalPages={totalPages}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } 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';
|
||||
@ -145,14 +145,16 @@ export const EditDocumentForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message } = data.email;
|
||||
const { subject, message, timezone, dateFormat } = data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
documentId: document.id,
|
||||
email: {
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
timezone,
|
||||
dateFormat,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -99,6 +99,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
};
|
||||
|
||||
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@ -164,6 +165,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
<DeleteDocumentDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
documentTitle={row.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
@ -21,6 +21,7 @@ type DeleteDraftDocumentDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
};
|
||||
|
||||
export const DeleteDocumentDialog = ({
|
||||
@ -28,6 +29,7 @@ export const DeleteDocumentDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
documentTitle,
|
||||
}: DeleteDraftDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
@ -42,7 +44,7 @@ export const DeleteDocumentDialog = ({
|
||||
|
||||
toast({
|
||||
title: 'Document deleted',
|
||||
description: 'Your document has been successfully deleted.',
|
||||
description: `"${documentTitle}" has been successfully deleted`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@ -50,6 +52,13 @@ export const DeleteDocumentDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInputValue('');
|
||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||
}
|
||||
}, [open, status]);
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
await deleteDocument({ id, status });
|
||||
@ -72,7 +81,7 @@ export const DeleteDocumentDialog = ({
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Do you want to delete this document?</DialogTitle>
|
||||
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that this action is irreversible. Once confirmed, your document will be
|
||||
@ -81,7 +90,7 @@ export const DeleteDocumentDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
{status !== DocumentStatus.DRAFT && (
|
||||
<div className="mt-8">
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
|
||||
@ -41,6 +41,7 @@ export const DuplicateDocumentDialog = ({
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: (newId) => {
|
||||
router.push(`/documents/${newId}`);
|
||||
|
||||
toast({
|
||||
title: 'Document Duplicated',
|
||||
description: 'Your document has been successfully duplicated.',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@ -25,6 +25,7 @@ export type UploadDocumentProps = {
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -35,6 +36,16 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (remaining.documents === 0) {
|
||||
return 'You have reached your document limit.';
|
||||
}
|
||||
|
||||
if (!session?.user.emailVerified) {
|
||||
return 'Verify your email to upload documents.';
|
||||
}
|
||||
}, [remaining.documents, session?.user.emailVerified]);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@ -90,6 +101,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
<DocumentDropzone
|
||||
className="min-h-[40vh]"
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
/>
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -1,46 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
getStripeCustomerByEmail,
|
||||
getStripeCustomerById,
|
||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getStripeCustomerByUser } 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-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 () => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
|
||||
let stripeCustomer: Stripe.Customer | null = null;
|
||||
|
||||
// Find the Stripe customer for the current user subscription.
|
||||
if (existingSubscription) {
|
||||
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
throw new Error('Missing Stripe customer for subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to check if a Stripe customer already exists for the current user email.
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||
}
|
||||
|
||||
// Create a Stripe customer if it does not exist for the current user.
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
const { stripeCustomer } = await getStripeCustomerByUser(user);
|
||||
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
|
||||
@ -1,55 +1,36 @@
|
||||
'use server';
|
||||
|
||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import {
|
||||
getStripeCustomerByEmail,
|
||||
getStripeCustomerById,
|
||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getStripeCustomerByUser } 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-component-session';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
|
||||
export type CreateCheckoutOptions = {
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
const { user, stripeCustomer } = await getStripeCustomerByUser(session.user);
|
||||
|
||||
let stripeCustomer: Stripe.Customer | null = null;
|
||||
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
|
||||
|
||||
// Find the Stripe customer for the current user subscription.
|
||||
if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) {
|
||||
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
throw new Error('Missing Stripe customer for subscription');
|
||||
}
|
||||
const foundSubscription = existingSubscriptions.find(
|
||||
(subscription) =>
|
||||
subscription.priceId === priceId &&
|
||||
subscription.periodEnd &&
|
||||
subscription.periodEnd >= new Date(),
|
||||
);
|
||||
|
||||
if (foundSubscription) {
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to check if a Stripe customer already exists for the current user email.
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||
}
|
||||
|
||||
// Create a Stripe customer if it does not exist for the current user.
|
||||
if (!stripeCustomer) {
|
||||
await createCustomer({
|
||||
user,
|
||||
});
|
||||
|
||||
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||
}
|
||||
|
||||
return getCheckoutSession({
|
||||
customerId: stripeCustomer.id,
|
||||
priceId,
|
||||
|
||||
@ -2,12 +2,15 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
@ -15,7 +18,7 @@ import { BillingPlans } from './billing-plans';
|
||||
import { BillingPortalButton } from './billing-portal-button';
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
let { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||
|
||||
@ -24,20 +27,36 @@ export default async function BillingSettingsPage() {
|
||||
redirect('/settings/profile');
|
||||
}
|
||||
|
||||
const [subscription, prices] = await Promise.all([
|
||||
getSubscriptionByUserId({ userId: user.id }),
|
||||
getPricesByInterval(),
|
||||
if (!user.customerId) {
|
||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||
}
|
||||
|
||||
const [subscriptions, prices, individualPrices] = await Promise.all([
|
||||
getSubscriptionsByUserId({ userId: user.id }),
|
||||
getPricesByInterval({ type: 'individual' }),
|
||||
getPricesByType('individual'),
|
||||
]);
|
||||
|
||||
const individualPriceIds = individualPrices.map(({ id }) => id);
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
individualPriceIds.includes(priceId),
|
||||
);
|
||||
|
||||
const subscription =
|
||||
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
individualUserSubscriptions[0];
|
||||
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
||||
const isMissingOrInactiveOrFreePlan =
|
||||
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
156
apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx
Normal file
156
apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
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';
|
||||
|
||||
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 { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
|
||||
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
|
||||
|
||||
const onAddTemplatePlaceholderFormSubmit = async (
|
||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||
) => {
|
||||
try {
|
||||
await addTemplateSigners({
|
||||
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>
|
||||
);
|
||||
};
|
||||
81
apps/web/src/app/(dashboard)/templates/[id]/page.tsx
Normal file
81
apps/web/src/app/(dashboard)/templates/[id]/page.tsx
Normal 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([
|
||||
getRecipientsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-8"
|
||||
template={template}
|
||||
user={user}
|
||||
recipients={templateRecipients}
|
||||
fields={templateFields}
|
||||
documentData={templateDocumentData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
138
apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
Normal file
138
apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
26
apps/web/src/app/(dashboard)/templates/data-table-title.tsx
Normal file
26
apps/web/src/app/(dashboard)/templates/data-table-title.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,84 @@
|
||||
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 DeleteTemplateDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: 'Template deleted',
|
||||
description: 'Your template has been successfully deleted.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDeleteTemplate = async () => {
|
||||
try {
|
||||
await deleteTemplate({ id });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This template could not be deleted at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Do you want to delete this template?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that this action is irreversible. Once confirmed, your template will be
|
||||
permanently deleted.
|
||||
</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={onDeleteTemplate} className="flex-1">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,87 @@
|
||||
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,
|
||||
});
|
||||
} 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>
|
||||
);
|
||||
};
|
||||
17
apps/web/src/app/(dashboard)/templates/empty-state.tsx
Normal file
17
apps/web/src/app/(dashboard)/templates/empty-state.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
228
apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx
Normal file
228
apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx
Normal 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>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
resolver: zodResolver(ZCreateTemplateFormSchema),
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
52
apps/web/src/app/(dashboard)/templates/page.tsx
Normal file
52
apps/web/src/app/(dashboard)/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -15,6 +15,8 @@ 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 { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
params: {
|
||||
token?: string;
|
||||
@ -36,6 +38,8 @@ export default async function CompletedSigningPage({
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
@ -89,7 +93,7 @@ export default async function CompletedSigningPage({
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
You have signed
|
||||
<span className="mt-1.5 block">"{document.title}"</span>
|
||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||
</h2>
|
||||
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
|
||||
@ -6,8 +6,13 @@ 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 {
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
convertToLocalSystemFormat,
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@ -16,9 +21,16 @@ import { SigningFieldContainer } from './signing-field-container';
|
||||
export type DateFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
dateFormat?: string | null;
|
||||
timezone?: string | null;
|
||||
};
|
||||
|
||||
export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
export const DateField = ({
|
||||
field,
|
||||
recipient,
|
||||
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
}: DateFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -35,12 +47,18 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
|
||||
|
||||
const isDifferentTime = field.inserted && localDateString !== field.customText;
|
||||
|
||||
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
||||
|
||||
const onSign = async () => {
|
||||
try {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: '',
|
||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
@ -75,7 +93,13 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
<SigningFieldContainer
|
||||
field={field}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Date"
|
||||
tooltipText={isDifferentTime ? tooltipText : undefined}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
@ -87,7 +111,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
|
||||
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
|
||||
@ -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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
|
||||
@ -4,10 +4,18 @@ import { useMemo, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
|
||||
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||
import {
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
MIN_HANDWRITING_FONT_SIZE,
|
||||
} from '@documenso/lib/constants/pdf';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -28,26 +36,67 @@ export type SigningFormProps = {
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
||||
const ZSigningpadSchema = z.union([
|
||||
z.object({
|
||||
signatureDataUrl: z.string().min(1),
|
||||
signatureText: z.null().or(z.string().max(0)),
|
||||
}),
|
||||
z.object({
|
||||
signatureDataUrl: z.null().or(z.string().max(0)),
|
||||
signatureText: z.string().trim().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TSigningpadSchema = z.infer<typeof ZSigningpadSchema>;
|
||||
|
||||
export const SigningForm = ({ document: _document, recipient, fields }: SigningFormProps) => {
|
||||
const fontVariable = '--font-signature';
|
||||
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
|
||||
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
|
||||
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
fontVariable,
|
||||
);
|
||||
|
||||
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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
} = useForm();
|
||||
} = useForm<TSigningpadSchema>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
signatureDataUrl: signature || null,
|
||||
signatureText: '',
|
||||
},
|
||||
resolver: zodResolver(ZSigningpadSchema),
|
||||
});
|
||||
|
||||
const { height, width } = useFieldPageCoords(fields.find((field) => field.type === 'SIGNATURE')!);
|
||||
|
||||
const signatureDataUrl = watch('signatureDataUrl');
|
||||
const signatureText = watch('signatureText');
|
||||
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
}, [fields]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
validateFieldsInserted(fields);
|
||||
};
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
setValidateUninsertedFields(true);
|
||||
|
||||
@ -59,18 +108,30 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token: recipient.token,
|
||||
documentId: document.id,
|
||||
documentId: _document.id,
|
||||
});
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: document.id,
|
||||
documentId: _document.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`/sign/${recipient.token}/complete`);
|
||||
};
|
||||
|
||||
const scalingFactor = useElementScaleSize(
|
||||
{
|
||||
height,
|
||||
width,
|
||||
},
|
||||
signatureText || '',
|
||||
maxFontSize,
|
||||
fontVariableValue,
|
||||
);
|
||||
|
||||
const fontSize = maxFontSize * scalingFactor;
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn(
|
||||
@ -92,7 +153,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
disabled={isSubmitting}
|
||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||
>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<div
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
@ -118,15 +183,79 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<Card id="signature" className="mt-4" degrees={-120} gradient>
|
||||
<CardContent role="button" className="relative cursor-pointer pt-6">
|
||||
<div className="flex h-44 max-w-[18rem] items-center justify-center pb-6">
|
||||
{!signatureText && (
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
className="h-44"
|
||||
defaultValue={signature ?? undefined}
|
||||
clearSignatureClassName="absolute -bottom-6 -right-2 z-10 cursor-pointer"
|
||||
undoSignatureClassName="absolute -top-32 -left-4 z-10 cursor-pointer"
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signatureText && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
|
||||
fontFamily: `var(${fontVariable})`,
|
||||
}}
|
||||
className={cn(
|
||||
'text-foreground font-signature max-w-[18rem] text-4xl font-semibold',
|
||||
)}
|
||||
>
|
||||
{signatureText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex cursor-auto items-end justify-between px-4 pb-1 pt-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
id="signatureText"
|
||||
className="text-foreground placeholder:text-muted-foreground max-w-[15rem] border-0 border-none bg-transparent p-0 text-sm focus-visible:ring-transparent"
|
||||
placeholder="Draw or type your name here"
|
||||
disabled={isSubmitting || signature?.startsWith('data:')}
|
||||
{...register('signatureText', {
|
||||
onChange: (e) => {
|
||||
if (e.target.value !== '') {
|
||||
setValue('signatureDataUrl', null);
|
||||
}
|
||||
|
||||
setValue('signatureText', e.target.value);
|
||||
},
|
||||
|
||||
onBlur: (e) => {
|
||||
if (e.target.value === '') {
|
||||
return setValue('signatureText', '');
|
||||
}
|
||||
|
||||
setSignature(e.target.value.trimStart());
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{signatureText && (
|
||||
<div className="absolute bottom-3 right-4 z-10 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
setValue('signatureText', '');
|
||||
setValue('signatureDataUrl', null);
|
||||
}}
|
||||
>
|
||||
Clear Signature
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@ -147,8 +276,9 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
document={_document}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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';
|
||||
@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
@ -2,9 +2,12 @@ import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
@ -14,6 +17,8 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { DateField } from './date-field';
|
||||
import { EmailField } from './email-field';
|
||||
import { SigningForm } from './form';
|
||||
@ -42,10 +47,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
viewedDocument({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const { user } = await getServerComponentSession();
|
||||
@ -77,7 +86,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
>
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
{truncatedTitle}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
@ -111,7 +120,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
<NameField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField key={field.id} field={field} recipient={recipient} />
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Document, Field } from '@documenso/prisma/client';
|
||||
import type { Document, Field } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -9,10 +9,13 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
document: Document;
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@ -20,30 +23,31 @@ export const SignDialog = ({
|
||||
isSubmitting,
|
||||
document,
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={!isComplete}
|
||||
onClick={fieldsValidated}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Complete
|
||||
{isComplete ? 'Complete' : 'Next field'}
|
||||
</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?
|
||||
You are about to finish signing "{truncatedTitle}". Are you sure?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -86,7 +86,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value,
|
||||
isBase64: true,
|
||||
isBase64: typeof value === 'string' && value.startsWith('data:image/png;base64,'),
|
||||
});
|
||||
|
||||
if (source === 'local' && !providedSignature) {
|
||||
@ -127,7 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
@ -11,6 +12,8 @@ export type SignatureFieldProps = {
|
||||
children: React.ReactNode;
|
||||
onSign?: () => Promise<void> | void;
|
||||
onRemove?: () => Promise<void> | void;
|
||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
||||
tooltipText?: string | null;
|
||||
};
|
||||
|
||||
export const SigningFieldContainer = ({
|
||||
@ -19,6 +22,8 @@ export const SigningFieldContainer = ({
|
||||
onSign,
|
||||
onRemove,
|
||||
children,
|
||||
type,
|
||||
tooltipText,
|
||||
}: SignatureFieldProps) => {
|
||||
const onSignFieldClick = async () => {
|
||||
if (field.inserted) {
|
||||
@ -46,7 +51,22 @@ export const SigningFieldContainer = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.inserted && !loading && (
|
||||
{type === 'Date' && field.inserted && !loading && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{type !== 'Date' && field.inserted && !loading && (
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
|
||||
@ -13,12 +13,14 @@ export default function SignInPage() {
|
||||
|
||||
<SignInForm className="mt-4" />
|
||||
|
||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2.5 text-center">
|
||||
<Link
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
|
||||
export default function SignUpPage() {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||
|
||||
@ -11,6 +11,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
DOCUMENTS_PAGE_SHORTCUT,
|
||||
SETTINGS_PAGE_SHORTCUT,
|
||||
TEMPLATES_PAGE_SHORTCUT,
|
||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
@ -22,6 +23,7 @@ import {
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { THEMES_TYPE } from '@documenso/ui/primitives/constants';
|
||||
|
||||
const DOCUMENTS_PAGES = [
|
||||
{
|
||||
@ -38,6 +40,14 @@ const DOCUMENTS_PAGES = [
|
||||
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
||||
];
|
||||
|
||||
const TEMPLATES_PAGES = [
|
||||
{
|
||||
label: 'All templates',
|
||||
path: '/templates',
|
||||
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
];
|
||||
|
||||
const SETTINGS_PAGES = [
|
||||
{
|
||||
label: 'Settings',
|
||||
@ -79,8 +89,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
return searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: `/documents/${document.id}`,
|
||||
value:
|
||||
document.title + ' ' + document.Recipient.map((recipient) => recipient.email).join(' '),
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
}));
|
||||
}, [searchDocumentsData]);
|
||||
|
||||
@ -124,10 +133,12 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
|
||||
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
||||
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
||||
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
|
||||
|
||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen);
|
||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
|
||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Escape goes to previous page
|
||||
@ -174,6 +185,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
<CommandGroup heading="Documents">
|
||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Templates">
|
||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Settings">
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
@ -215,9 +229,9 @@ const Commands = ({
|
||||
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
|
||||
const THEMES = useMemo(
|
||||
() => [
|
||||
{ label: 'Light Mode', theme: 'light', icon: Sun },
|
||||
{ label: 'Dark Mode', theme: 'dark', icon: Moon },
|
||||
{ label: 'System Theme', theme: 'system', icon: Monitor },
|
||||
{ label: 'Light Mode', theme: THEMES_TYPE.LIGHT, icon: Sun },
|
||||
{ label: 'Dark Mode', theme: THEMES_TYPE.DARK, icon: Moon },
|
||||
{ label: 'System Theme', theme: THEMES_TYPE.SYSTEM, icon: Monitor },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -10,10 +13,22 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { CommandMenu } from '../common/command-menu';
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
href: '/documents',
|
||||
label: 'Documents',
|
||||
},
|
||||
{
|
||||
href: '/templates',
|
||||
label: 'Templates',
|
||||
},
|
||||
];
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
// const pathname = usePathname();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||
|
||||
@ -26,9 +41,29 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('ml-8 hidden flex-1 gap-x-6 md:flex md:justify-center', className)}
|
||||
className={cn(
|
||||
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-baseline gap-x-6">
|
||||
{navigationLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||
|
||||
<Button
|
||||
@ -47,19 +82,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* We have no other subpaths rn */}
|
||||
{/* <Link
|
||||
href="/documents"
|
||||
className={cn(
|
||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground': pathname?.startsWith('/documents'),
|
||||
},
|
||||
)}
|
||||
>
|
||||
Documents
|
||||
</Link> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
@ -32,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[50] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
@ -48,7 +49,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
|
||||
<DesktopNav />
|
||||
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<ProfileDropdown user={user} />
|
||||
|
||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||
|
||||
@ -4,6 +4,7 @@ import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
FileSpreadsheet,
|
||||
Lock,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
@ -106,6 +107,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/templates" className="cursor-pointer">
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Templates
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||
|
||||
|
||||
50
apps/web/src/components/formatter/template-type.tsx
Normal file
50
apps/web/src/components/formatter/template-type.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { Globe, Lock } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
type TemplateTypeIcon = {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma];
|
||||
|
||||
const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
|
||||
PRIVATE: {
|
||||
label: 'Private',
|
||||
icon: Lock,
|
||||
color: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
PUBLIC: {
|
||||
label: 'Public',
|
||||
icon: Globe,
|
||||
color: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
};
|
||||
|
||||
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
type: TemplateTypes;
|
||||
inheritColor?: boolean;
|
||||
};
|
||||
|
||||
export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => {
|
||||
const { label, icon: Icon, color } = TEMPLATE_TYPES[type];
|
||||
|
||||
return (
|
||||
<span className={cn('flex items-center', className)} {...props}>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn('mr-2 inline-block h-4 w-4', {
|
||||
[color]: !inheritColor,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@ -23,6 +23,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
||||
@ -106,6 +107,10 @@ export const DisableAuthenticatorAppDialog = ({
|
||||
onDisableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<fieldset
|
||||
className="flex flex-col gap-y-4"
|
||||
disabled={isDisableTwoFactorAuthenticationSubmitting}
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
@ -114,9 +119,8 @@ export const DisableAuthenticatorAppDialog = ({
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
<PasswordInput
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
@ -139,6 +143,7 @@ export const DisableAuthenticatorAppDialog = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
@ -178,9 +179,8 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
<PasswordInput
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
|
||||
@ -22,7 +22,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
@ -108,9 +108,8 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
<PasswordInput
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
|
||||
@ -9,9 +9,15 @@ import { z } from 'zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZForgotPasswordFormSchema = z.object({
|
||||
@ -28,18 +34,15 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TForgotPasswordFormSchema>({
|
||||
const form = useForm<TForgotPasswordFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
},
|
||||
resolver: zodResolver(ZForgotPasswordFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
|
||||
@ -52,29 +55,37 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
reset();
|
||||
form.reset();
|
||||
|
||||
router.push('/check-email');
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.email} />
|
||||
</div>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
{isSubmitting ? 'Sending Reset Email...' : 'Reset Password'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,23 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
export const ZPasswordFormSchema = z
|
||||
.object({
|
||||
currentPassword: z
|
||||
@ -48,16 +50,7 @@ export type PasswordFormProps = {
|
||||
export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TPasswordFormSchema>({
|
||||
const form = useForm<TPasswordFormSchema>({
|
||||
values: {
|
||||
currentPassword: '',
|
||||
password: '',
|
||||
@ -66,6 +59,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
resolver: zodResolver(ZPasswordFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
|
||||
@ -75,7 +70,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
password,
|
||||
});
|
||||
|
||||
reset();
|
||||
form.reset();
|
||||
|
||||
toast({
|
||||
title: 'Password updated',
|
||||
@ -101,117 +96,61 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="current-password" className="text-muted-foreground">
|
||||
Current Password
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="current-password"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('currentPassword')}
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="current-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showCurrentPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowCurrentPassword((show) => !show)}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.currentPassword} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repeatedPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repeat Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="repeated-password" className="text-muted-foreground">
|
||||
Repeat Password
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowConfirmPassword((show) => !show)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
Update password
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? 'Updating password...' : 'Update password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@ -3,22 +3,27 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
export const ZProfileFormSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||
@ -36,12 +41,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TProfileFormSchema>({
|
||||
const form = useForm<TProfileFormSchema>({
|
||||
values: {
|
||||
name: user.name ?? '',
|
||||
signature: user.signature || '',
|
||||
@ -49,6 +49,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
resolver: zodResolver(ZProfileFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
||||
@ -84,56 +86,57 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="full-name" className="text-muted-foreground">
|
||||
Full Name
|
||||
</Label>
|
||||
|
||||
<Input id="full-name" type="text" className="bg-background mt-2" {...register('name')} />
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.name} />
|
||||
</div>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="signature" className="text-muted-foreground">
|
||||
Signature
|
||||
</Label>
|
||||
|
||||
<div className="mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signature"
|
||||
render={({ field: { onChange } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Signature</FormLabel>
|
||||
<FormControl>
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
containerClassName="rounded-lg border bg-background"
|
||||
defaultValue={user.signature ?? undefined}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
Update profile
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? 'Updating profile...' : 'Update profile'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -13,9 +10,15 @@ import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZResetPasswordFormSchema = z
|
||||
@ -40,15 +43,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TResetPasswordFormSchema>({
|
||||
const form = useForm<TResetPasswordFormSchema>({
|
||||
values: {
|
||||
password: '',
|
||||
repeatedPassword: '',
|
||||
@ -56,6 +51,8 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
|
||||
resolver: zodResolver(ZResetPasswordFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
|
||||
@ -65,7 +62,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
|
||||
token,
|
||||
});
|
||||
|
||||
reset();
|
||||
form.reset();
|
||||
|
||||
toast({
|
||||
title: 'Password updated',
|
||||
@ -93,81 +90,45 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repeatedPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repeat Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="repeatedPassword" className="text-muted-foreground">
|
||||
<span>Repeat Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowConfirmPassword((show) => !show)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||
{isSubmitting ? 'Resetting Password...' : 'Reset Password'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@ -12,9 +12,16 @@ import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input, PasswordInput } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||
@ -52,12 +59,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TSignInFormSchema>({
|
||||
const form = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
password: '',
|
||||
@ -67,9 +69,11 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
resolver: zodResolver(ZSignInFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onCloseTwoFactorAuthenticationDialog = () => {
|
||||
setValue('totpCode', '');
|
||||
setValue('backupCode', '');
|
||||
form.setValue('totpCode', '');
|
||||
form.setValue('backupCode', '');
|
||||
|
||||
setIsTwoFactorAuthenticationDialogOpen(false);
|
||||
};
|
||||
@ -78,11 +82,11 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
|
||||
|
||||
if (method === 'totp') {
|
||||
setValue('backupCode', '');
|
||||
form.setValue('backupCode', '');
|
||||
}
|
||||
|
||||
if (method === 'backup') {
|
||||
setValue('totpCode', '');
|
||||
form.setValue('totpCode', '');
|
||||
}
|
||||
|
||||
setTwoFactorAuthenticationMethod(method);
|
||||
@ -113,7 +117,6 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
if (result?.error && isErrorCode(result.error)) {
|
||||
if (result.error === TwoFactorEnabledErrorCode) {
|
||||
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -156,41 +159,45 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-muted-forground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.email} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-muted-forground">
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<PasswordInput
|
||||
id="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
className="bg-background mt-2"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
className="dark:bg-documenso dark:hover:opacity-90"
|
||||
>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||
@ -213,7 +220,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
|
||||
</form>
|
||||
<Dialog
|
||||
open={isTwoFactorAuthenticationDialogOpen}
|
||||
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||
@ -223,40 +230,40 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
{twoFactorAuthenticationMethod === 'totp' && (
|
||||
<div>
|
||||
<Label htmlFor="totpCode" className="text-muted-forground">
|
||||
Authentication Token
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="totpCode"
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
{...register('totpCode')}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.totpCode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFactorAuthenticationMethod === 'backup' && (
|
||||
<div>
|
||||
<Label htmlFor="backupCode" className="text-muted-forground">
|
||||
Backup Code
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="backupCode"
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
{...register('backupCode')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel> Backup Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<Button
|
||||
@ -268,12 +275,12 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Sign In
|
||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
@ -13,9 +10,16 @@ import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@ -38,14 +42,8 @@ export type SignUpFormProps = {
|
||||
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TSignUpFormSchema>({
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
email: '',
|
||||
@ -55,6 +53,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
resolver: zodResolver(ZSignUpFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
||||
@ -90,93 +90,83 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-muted-foreground">
|
||||
Name
|
||||
</Label>
|
||||
|
||||
<Input id="name" type="text" className="bg-background mt-2" {...register('name')} />
|
||||
|
||||
{errors.name && <span className="mt-1 text-xs text-red-500">{errors.name.message}</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||
|
||||
{errors.email && <span className="mt-1 text-xs text-red-500">{errors.email.message}</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
Sign Here
|
||||
</Label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signature"
|
||||
render={({ field: { onChange } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sign Here</FormLabel>
|
||||
<FormControl>
|
||||
<SignaturePad
|
||||
className="h-36 w-full"
|
||||
containerClassName="mt-2 rounded-lg border bg-background"
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
className="dark:bg-documenso dark:hover:opacity-90"
|
||||
>
|
||||
{isSubmitting ? 'Signing up...' : 'Sign Up'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
10
apps/web/src/helpers/truncate-title.ts
Normal file
10
apps/web/src/helpers/truncate-title.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const truncateTitle = (title: string, maxLength: number = 16) => {
|
||||
if (title.length <= maxLength) {
|
||||
return title;
|
||||
}
|
||||
|
||||
const start = title.slice(0, maxLength / 2);
|
||||
const end = title.slice(-maxLength / 2);
|
||||
|
||||
return `${start}.....${end}`;
|
||||
};
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Session } from 'next-auth';
|
||||
import type { Session } from 'next-auth';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export type NextAuthProviderProps = {
|
||||
|
||||
@ -17,3 +17,20 @@ services:
|
||||
- 9000:9000
|
||||
- 2500:2500
|
||||
- 1100:1100
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
container_name: minio
|
||||
ports:
|
||||
- 9002:9002
|
||||
- 9001:9001
|
||||
volumes:
|
||||
- minio:/data
|
||||
environment:
|
||||
MINIO_ROOT_USER: documenso
|
||||
MINIO_ROOT_PASSWORD: password
|
||||
entrypoint: sh
|
||||
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
||||
|
||||
volumes:
|
||||
minio:
|
||||
|
||||
@ -33,7 +33,6 @@ services:
|
||||
- SMTP_MAIL_USER=username
|
||||
- SMTP_MAIL_PASSWORD=password
|
||||
- MAIL_FROM=admin@example.com
|
||||
- NEXT_PUBLIC_ALLOW_SIGNUP=true
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
/** @type {import('lint-staged').Config} */
|
||||
module.exports = {
|
||||
'**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'],
|
||||
'**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`,
|
||||
'**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`,
|
||||
'**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`,
|
||||
'**/*/package.json': 'npm run precommit',
|
||||
};
|
||||
|
||||
985
package-lock.json
generated
985
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -22,7 +22,8 @@
|
||||
"prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma",
|
||||
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
||||
"with:env": "dotenv -e .env -e .env.local --",
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
|
||||
"precommit": "npm install && git add package.json package-lock.json"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.6.0",
|
||||
@ -42,7 +43,6 @@
|
||||
"turbo": "^1.9.3"
|
||||
},
|
||||
"name": "@documenso/root",
|
||||
"packageManager": "npm@8.19.2",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
|
||||
BIN
packages/assets/fonts/dancing-script.ttf
Normal file
BIN
packages/assets/fonts/dancing-script.ttf
Normal file
Binary file not shown.
@ -1,10 +1,10 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getPricesByType } from '../stripe/get-prices-by-type';
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { ZLimitsSchema } from './schema';
|
||||
@ -43,24 +43,30 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
let quota = structuredClone(FREE_PLAN_LIMITS);
|
||||
let remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||
|
||||
// Since we store details and allow for past due plans we need to check if the subscription is active.
|
||||
if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) {
|
||||
const { product } = await stripe.prices
|
||||
.retrieve(user.Subscription.priceId, {
|
||||
expand: ['product'],
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
const activeSubscriptions = user.Subscription.filter(
|
||||
({ status }) => status === SubscriptionStatus.ACTIVE,
|
||||
);
|
||||
|
||||
if (typeof product === 'string') {
|
||||
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
|
||||
if (activeSubscriptions.length > 0) {
|
||||
const individualPrices = await getPricesByType('individual');
|
||||
|
||||
for (const subscription of activeSubscriptions) {
|
||||
const price = individualPrices.find((price) => price.id === subscription.priceId);
|
||||
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
|
||||
const currentQuota = ZLimitsSchema.parse(
|
||||
'metadata' in price.product ? price.product.metadata : {},
|
||||
);
|
||||
|
||||
// Use the subscription with the highest quota.
|
||||
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
|
||||
quota = currentQuota;
|
||||
remaining = structuredClone(quota);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const documents = await prisma.document.count({
|
||||
where: {
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
export type CreateCustomerOptions = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const createCustomer = async ({ user }: CreateCustomerOptions) => {
|
||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
|
||||
if (existingSubscription) {
|
||||
throw new Error('User already has a subscription');
|
||||
}
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.subscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
customerId: customer.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,4 +1,8 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
|
||||
|
||||
export const getStripeCustomerByEmail = async (email: string) => {
|
||||
const foundStripeCustomers = await stripe.customers.list({
|
||||
@ -17,3 +21,74 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a stripe customer by user.
|
||||
*
|
||||
* Will create a Stripe customer and update the relevant user if one does not exist.
|
||||
*/
|
||||
export const getStripeCustomerByUser = async (user: User) => {
|
||||
if (user.customerId) {
|
||||
const stripeCustomer = await getStripeCustomerById(user.customerId);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
throw new Error('Missing Stripe customer');
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
stripeCustomer,
|
||||
};
|
||||
}
|
||||
|
||||
let stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||
|
||||
const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
customerId: stripeCustomer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync subscriptions if the customer already exists for back filling the DB
|
||||
// and local development.
|
||||
if (isSyncRequired) {
|
||||
await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user: updatedUser,
|
||||
stripeCustomer,
|
||||
};
|
||||
};
|
||||
|
||||
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
stripeSubscriptions.data.map(async (subscription) =>
|
||||
onSubscriptionUpdated({
|
||||
userId,
|
||||
subscription,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import Stripe from 'stripe';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
@ -7,7 +7,14 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
||||
|
||||
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
|
||||
|
||||
export const getPricesByInterval = async () => {
|
||||
export type GetPricesByIntervalOptions = {
|
||||
/**
|
||||
* Filter products by their meta 'type' attribute.
|
||||
*/
|
||||
type?: 'individual';
|
||||
};
|
||||
|
||||
export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => {
|
||||
let { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
@ -19,8 +26,10 @@ export const getPricesByInterval = async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = price.product as Stripe.Product;
|
||||
|
||||
const filter = !type || product.metadata?.type === type;
|
||||
|
||||
// Filter out prices for products that are not active.
|
||||
return product.active;
|
||||
return product.active && filter;
|
||||
});
|
||||
|
||||
const intervals: PriceIntervals = {
|
||||
|
||||
11
packages/ee/server-only/stripe/get-prices-by-type.ts
Normal file
11
packages/ee/server-only/stripe/get-prices-by-type.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export const getPricesByType = async (type: 'individual') => {
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query: `metadata['type']:'${type}' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user