Compare commits
2 Commits
feat/doc-c
...
exp/millio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599dc9bd98 | ||
|
|
f270ea4b8e |
21
.env.example
21
.env.example
@@ -2,11 +2,6 @@
|
|||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="secret"
|
NEXTAUTH_SECRET="secret"
|
||||||
|
|
||||||
# [[CRYPTO]]
|
|
||||||
# Application Key for symmetric encryption and decryption
|
|
||||||
# This should be a random string of at least 32 characters
|
|
||||||
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
|
||||||
|
|
||||||
# [[AUTH OPTIONAL]]
|
# [[AUTH OPTIONAL]]
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
@@ -29,15 +24,15 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
|||||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||||
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||||
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
|
NEXT_PRIVATE_UPLOAD_ENDPOINT=
|
||||||
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||||
NEXT_PRIVATE_UPLOAD_REGION="unknown"
|
NEXT_PRIVATE_UPLOAD_REGION=
|
||||||
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_BUCKET="documenso"
|
NEXT_PRIVATE_UPLOAD_BUCKET=
|
||||||
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
|
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
|
||||||
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
|
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
# [[SMTP]]
|
# [[SMTP]]
|
||||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||||
@@ -77,14 +72,16 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
|||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||||
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
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.
|
# OPTIONAL: Leave blank to disable billing.
|
||||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
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
|
# This is only required for the marketing site
|
||||||
# [[REDIS]]
|
# [[REDIS]]
|
||||||
|
|||||||
7
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
7
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,10 +1,11 @@
|
|||||||
name: 'Bug Report'
|
name: "Bug Report"
|
||||||
labels: ['bug']
|
labels: ["bug"]
|
||||||
description: Create a bug report to help us improve
|
description: Create a bug report to help us improve
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
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.
|
Please fill in as much of the form below as you're able to.
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
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
|
description: Suggest a new idea or enhancement for this project
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- 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
|
description: Suggest a minor enhancement or improvement for this project
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|||||||
32
.github/dependabot.yml
vendored
32
.github/dependabot.yml
vendored
@@ -4,29 +4,29 @@ updates:
|
|||||||
- package-ecosystem: 'github-actions'
|
- package-ecosystem: 'github-actions'
|
||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: "weekly"
|
||||||
target-branch: 'main'
|
target-branch: "main"
|
||||||
labels:
|
labels:
|
||||||
- 'ci dependencies'
|
- "ci dependencies"
|
||||||
- 'ci'
|
- "ci"
|
||||||
open-pull-requests-limit: 0
|
open-pull-requests-limit: 0
|
||||||
|
|
||||||
- package-ecosystem: 'npm'
|
- package-ecosystem: "npm"
|
||||||
directory: '/apps/marketing'
|
directory: "/apps/marketing"
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: "weekly"
|
||||||
target-branch: 'main'
|
target-branch: "main"
|
||||||
labels:
|
labels:
|
||||||
- 'npm dependencies'
|
- "npm dependencies"
|
||||||
- 'frontend'
|
- "frontend"
|
||||||
open-pull-requests-limit: 0
|
open-pull-requests-limit: 0
|
||||||
|
|
||||||
- package-ecosystem: 'npm'
|
- package-ecosystem: "npm"
|
||||||
directory: '/apps/web'
|
directory: "/apps/web"
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: "weekly"
|
||||||
target-branch: 'main'
|
target-branch: "main"
|
||||||
labels:
|
labels:
|
||||||
- 'npm dependencies'
|
- "npm dependencies"
|
||||||
- 'frontend'
|
- "frontend"
|
||||||
open-pull-requests-limit: 0
|
open-pull-requests-limit: 0
|
||||||
|
|||||||
21
.github/labeler.yml
vendored
21
.github/labeler.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
'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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main']
|
branches: [ "main" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ['main']
|
branches: [ "main" ]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||||
@@ -19,12 +19,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -43,9 +43,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
run: ./docker/build.sh
|
run: ./docker/build.sh
|
||||||
|
|
||||||
|
|||||||
12
.github/workflows/codeql-analysis.yml
vendored
12
.github/workflows/codeql-analysis.yml
vendored
@@ -1,11 +1,11 @@
|
|||||||
name: 'CodeQL'
|
name: "CodeQL"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: ['main']
|
branches: [ "main" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ['main']
|
branches: [ "main" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
@@ -19,13 +19,13 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
language: ['javascript']
|
language: [ 'javascript' ]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
24
.github/workflows/deploy.yml
vendored
24
.github/workflows/deploy.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
name: Deploy to Production
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.GH_TOKEN }}
|
|
||||||
|
|
||||||
- name: Push to release branch
|
|
||||||
run: |
|
|
||||||
git checkout release || git checkout -b release
|
|
||||||
git merge --ff-only main
|
|
||||||
git push origin release
|
|
||||||
41
.github/workflows/e2e-tests.yml
vendored
41
.github/workflows/e2e-tests.yml
vendored
@@ -1,50 +1,51 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main']
|
branches: [ "main" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ['main']
|
branches: [ "main" ]
|
||||||
jobs:
|
jobs:
|
||||||
e2e_tests:
|
e2e_tests:
|
||||||
name: "E2E Tests"
|
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: npm
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Copy env
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Start Services
|
|
||||||
run: npm run dx:up
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
- name: Generate Prisma Client
|
||||||
run: npm run prisma:generate -w @documenso/prisma
|
run: npm run prisma:generate -w @documenso/prisma
|
||||||
|
|
||||||
- name: Create the database
|
- name: Create the database
|
||||||
run: npm run prisma:migrate-dev
|
run: npm run prisma:migrate-dev
|
||||||
|
|
||||||
- name: Seed the database
|
|
||||||
run: npm run prisma:seed
|
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: playwright-report
|
||||||
path: "packages/app-tests/**/test-results/*"
|
path: playwright-report/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
env:
|
env:
|
||||||
|
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
||||||
|
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|||||||
29
.github/workflows/first-interaction.yml
vendored
29
.github/workflows/first-interaction.yml
vendored
@@ -1,29 +0,0 @@
|
|||||||
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
63
.github/workflows/issue-assignee-check.yml
vendored
@@ -1,63 +0,0 @@
|
|||||||
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
21
.github/workflows/issue-opened.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
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
20
.github/workflows/pr-labeler.yml
vendored
@@ -1,20 +0,0 @@
|
|||||||
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
64
.github/workflows/pr-review-reminder.yml
vendored
@@ -1,64 +0,0 @@
|
|||||||
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:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
@@ -9,54 +9,13 @@ on:
|
|||||||
- synchronize
|
- synchronize
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-pr:
|
validate-pr:
|
||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
id: lint_pr_title
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
24
.github/workflows/stale.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
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'
|
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,13 +1,10 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll.eslint": true
|
||||||
},
|
},
|
||||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"javascript.preferences.useAliasesForRenames": false,
|
"javascript.preferences.useAliasesForRenames": false,
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
"files.eol": "\n",
|
|
||||||
"editor.tabSize": 2,
|
|
||||||
"editor.insertSpaces": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -13,9 +13,9 @@
|
|||||||
·
|
·
|
||||||
<a href="https://github.com/documenso/documenso/issues">Issues</a>
|
<a href="https://github.com/documenso/documenso/issues">Issues</a>
|
||||||
·
|
·
|
||||||
<a href="https://documen.so/live">Upcoming Releases</a>
|
<a href="https://github.com/documenso/documenso/milestones">Roadmap</a>
|
||||||
·
|
·
|
||||||
<a href="https://documen.so/roadmap">Roadmap</a>
|
<a href="https://documen.so/launches">Upcoming Launches</a>
|
||||||
</p>
|
</p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -115,12 +115,10 @@ To run Documenso locally, you will need
|
|||||||
|
|
||||||
Want to get up and running quickly? Follow these steps:
|
Want to get up and running quickly? Follow these steps:
|
||||||
|
|
||||||
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
|
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||||
|
|
||||||
After forking the repository, clone it to your local device by using the following command:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/<your-username>/documenso
|
git clone https://github.com/documenso/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.
|
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.
|
||||||
@@ -141,25 +139,21 @@ npm run d
|
|||||||
|
|
||||||
1. **App** - http://localhost:3000
|
1. **App** - http://localhost:3000
|
||||||
2. **Incoming Mail Access** - http://localhost:9000
|
2. **Incoming Mail Access** - http://localhost:9000
|
||||||
3. **Database Connection Details**
|
|
||||||
|
|
||||||
|
3. **Database Connection Details**
|
||||||
- **Port**: 54320
|
- **Port**: 54320
|
||||||
- **Connection**: Use your favorite database client to connect using the provided port.
|
- **Connection**: Use your favorite database client to connect using the provided port.
|
||||||
|
|
||||||
4. **S3 Storage Dashboard** - http://localhost:9001
|
|
||||||
|
|
||||||
## Developer Setup
|
## Developer Setup
|
||||||
|
|
||||||
### Manual Setup
|
### Manual Setup
|
||||||
|
|
||||||
Follow these steps to setup Documenso on your local machine:
|
Follow these steps to setup Documenso on your local machine:
|
||||||
|
|
||||||
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
|
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||||
|
|
||||||
After forking the repository, clone it to your local device by using the following command:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/<your-username>/documenso
|
git clone https://github.com/documenso/documenso
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run `npm i` in the root directory
|
2. Run `npm i` in the root directory
|
||||||
@@ -246,7 +240,7 @@ Now you can install the dependencies and build it:
|
|||||||
|
|
||||||
```
|
```
|
||||||
npm i
|
npm i
|
||||||
npm run build:web
|
npm run:build:web
|
||||||
npm run prisma:migrate-deploy
|
npm run prisma:migrate-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -284,16 +278,12 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### Railway
|
### Railway
|
||||||
|
|
||||||
[](https://railway.app/template/bG6D4p)
|
[](https://railway.app/template/DjrRRX)
|
||||||
|
|
||||||
### Render
|
### Render
|
||||||
|
|
||||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
[](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
|
## Troubleshooting
|
||||||
|
|
||||||
### I'm not receiving any emails when using the developer quickstart.
|
### I'm not receiving any emails when using the developer quickstart.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
title: Careers at Documenso
|
|
||||||
---
|
|
||||||
|
|
||||||
# Careers at Documenso
|
|
||||||
|
|
||||||
So you love Documenso and all the things that we do and now you want to work with us to unlock the future of open signing?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Positions
|
|
||||||
|
|
||||||
Unfortunately we have no open positions available at the moment. Our team has grown and so we must grow with it, please check back from time to time as now is not forever and we may be hiring again in the future.
|
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const fs = require('fs');
|
import dotenv from 'dotenv';
|
||||||
const path = require('path');
|
import fs from 'fs';
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
import million from 'million/compiler';
|
||||||
|
import { withContentlayer } from 'next-contentlayer';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||||
|
|
||||||
ENV_FILES.forEach((file) => {
|
ENV_FILES.forEach((file) => {
|
||||||
require('dotenv').config({
|
dotenv.config({
|
||||||
path: path.join(__dirname, `../../${file}`),
|
path: path.join(__dirname, `../../${file}`),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -20,10 +27,8 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
|
serverActionsBodySizeLimit: '10mb',
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverActions: {
|
|
||||||
bodySizeLimit: '50mb',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
@@ -50,7 +55,7 @@ const config = {
|
|||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
async headers() {
|
headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
source: '/:path*',
|
||||||
@@ -84,7 +89,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
async rewrites() {
|
rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/ingest/:path*',
|
source: '/ingest/:path*',
|
||||||
@@ -94,4 +99,8 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withContentlayer(config);
|
export default million.next(withContentlayer(config), {
|
||||||
|
auto: {
|
||||||
|
rsc: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/marketing",
|
"name": "@documenso/marketing",
|
||||||
"version": "1.2.3",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,8 +24,9 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.3",
|
"million": "^2.6.4",
|
||||||
"next-auth": "4.24.5",
|
"next": "14.0.0",
|
||||||
|
"next-auth": "4.24.3",
|
||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"recharts": "^2.7.2",
|
"recharts": "^2.7.2",
|
||||||
"sharp": "0.33.1",
|
"sharp": "0.32.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@@ -44,13 +45,5 @@
|
|||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"next-auth": {
|
|
||||||
"next": "$next"
|
|
||||||
},
|
|
||||||
"next-contentlayer": {
|
|
||||||
"next": "$next"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/marketing/process-env.d.ts
vendored
2
apps/marketing/process-env.d.ts
vendored
@@ -6,6 +6,8 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: 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_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
|
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
|
||||||
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div>
|
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
|
||||||
|
|
||||||
<Footer className="bg-background border-muted mt-24 border-t" />
|
<Footer className="bg-background border-muted mt-24 border-t" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type MonthlyNewUsersChartProps = {
|
export type MonthlyNewUsersChartProps = {
|
||||||
@@ -22,7 +22,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn('flex flex-col', className)}>
|
||||||
<div className="flex items-center px-4">
|
<div className="flex items-center px-4">
|
||||||
<h3 className="text-lg font-semibold">New Users</h3>
|
<h3 className="text-lg font-semibold">Monthly New Users</h3>
|
||||||
</div>
|
</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">
|
<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 { DateTime } from 'luxon';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type MonthlyTotalUsersChartProps = {
|
export type MonthlyTotalUsersChartProps = {
|
||||||
@@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn('flex flex-col', className)}>
|
||||||
<div className="flex items-center px-4">
|
<div className="flex items-center px-4">
|
||||||
<h3 className="text-lg font-semibold">Total Users</h3>
|
<h3 className="text-lg font-semibold">Monthly Total Users</h3>
|
||||||
</div>
|
</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">
|
<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,7 +29,10 @@ export function OpenPageTooltip() {
|
|||||||
</svg>
|
</svg>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Active Subscriptions.</p>
|
<p>
|
||||||
|
August and earlier: Active subscribers. September and beyond: Numbers of active
|
||||||
|
subscriptions.
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add
|
|||||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||||
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const SinglePlayerModeSteps = ['fields', 'sign'] as const;
|
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||||
type SinglePlayerModeStep = (typeof SinglePlayerModeSteps)[number];
|
|
||||||
|
|
||||||
// !: This entire file is a hack to get around failed prerendering of
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
// !: the Single Player Mode page. This regression was introduced during
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
@@ -86,7 +87,6 @@ export const SinglePlayerClient = () => {
|
|||||||
data.fields.map((field, i) => ({
|
data.fields.map((field, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
templateId: null,
|
|
||||||
recipientId: -1,
|
recipientId: -1,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
page: field.pageNumber,
|
page: field.pageNumber,
|
||||||
@@ -149,7 +149,6 @@ export const SinglePlayerClient = () => {
|
|||||||
const placeholderRecipient: Recipient = {
|
const placeholderRecipient: Recipient = {
|
||||||
id: -1,
|
id: -1,
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
templateId: null,
|
|
||||||
email: '',
|
email: '',
|
||||||
name: '',
|
name: '',
|
||||||
token: '',
|
token: '',
|
||||||
@@ -227,35 +226,37 @@ export const SinglePlayerClient = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<DocumentFlowFormContainer
|
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
|
||||||
className="top-24 lg:h-[calc(100vh-7rem)]"
|
<DocumentFlowFormContainerHeader
|
||||||
onSubmit={(e) => e.preventDefault()}
|
title={currentDocumentFlow.title}
|
||||||
>
|
description={currentDocumentFlow.description}
|
||||||
<Stepper
|
/>
|
||||||
currentStep={currentDocumentFlow.stepIndex}
|
|
||||||
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
|
|
||||||
>
|
|
||||||
{/* Add fields to PDF page. */}
|
{/* Add fields to PDF page. */}
|
||||||
|
{step === 'fields' && (
|
||||||
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
hideRecipients={true}
|
hideRecipients={true}
|
||||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onFieldsSubmit}
|
onSubmit={onFieldsSubmit}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Enter user details and signature. */}
|
{/* Enter user details and signature. */}
|
||||||
|
{step === 'sign' && (
|
||||||
<AddSignatureFormPartial
|
<AddSignatureFormPartial
|
||||||
documentFlow={documentFlow.sign}
|
documentFlow={documentFlow.sign}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onSignSubmit}
|
onSubmit={onSignSubmit}
|
||||||
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||||
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
)}
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
160
apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx
Normal file
160
apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -31,7 +31,6 @@ const FOOTER_LINKS = [
|
|||||||
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
||||||
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
||||||
{ href: '/oss-friends', text: 'OSS Friends' },
|
{ href: '/oss-friends', text: 'OSS Friends' },
|
||||||
{ href: '/careers', text: 'Careers' },
|
|
||||||
{ href: '/privacy', text: 'Privacy' },
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<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 className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
||||||
<div className="flex-shrink-0">
|
<div>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Image
|
<Image
|
||||||
src={LogoImage}
|
src={LogoImage}
|
||||||
@@ -64,13 +63,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
<div className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2">
|
||||||
{FOOTER_LINKS.map((link, index) => (
|
{FOOTER_LINKS.map((link, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.target}
|
target={link.target}
|
||||||
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm"
|
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm"
|
||||||
>
|
>
|
||||||
{link.text}
|
{link.text}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useState } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
@@ -16,9 +16,14 @@ export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
|
|||||||
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
|
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
|
||||||
|
|
||||||
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||||
|
const params = useSearchParams();
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY');
|
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
||||||
|
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
||||||
|
? 'YEARLY'
|
||||||
|
: 'MONTHLY',
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('', className)} {...props}>
|
<div className={cn('', className)} {...props}>
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import type { Signature } from '@documenso/prisma/client';
|
import { DocumentStatus, Signature } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
|
||||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes, KeyboardEvent } from 'react';
|
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
@@ -27,7 +26,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||||
|
|
||||||
import { STEP } from '../constants';
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
|
|
||||||
const ZWidgetFormSchema = z
|
const ZWidgetFormSchema = z
|
||||||
@@ -50,16 +48,13 @@ const ZWidgetFormSchema = z
|
|||||||
|
|
||||||
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
|
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
|
||||||
|
|
||||||
type StepKeys = keyof typeof STEP;
|
|
||||||
type StepValues = (typeof STEP)[StepKeys];
|
|
||||||
|
|
||||||
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
|
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
const [step, setStep] = useState<StepValues>(STEP.EMAIL);
|
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
|
||||||
const [showSigningDialog, setShowSigningDialog] = useState(false);
|
const [showSigningDialog, setShowSigningDialog] = useState(false);
|
||||||
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
|
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -86,28 +81,28 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
const signatureText = watch('signatureText');
|
const signatureText = watch('signatureText');
|
||||||
|
|
||||||
const stepsRemaining = useMemo(() => {
|
const stepsRemaining = useMemo(() => {
|
||||||
if (step === STEP.NAME) {
|
if (step === 'NAME') {
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === STEP.EMAIL) {
|
if (step === 'SIGN') {
|
||||||
return 3;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 1;
|
return 3;
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
const onNextStepClick = () => {
|
const onNextStepClick = () => {
|
||||||
if (step === STEP.EMAIL) {
|
if (step === 'EMAIL') {
|
||||||
setStep(STEP.NAME);
|
setStep('NAME');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.querySelector<HTMLElement>('#name')?.focus();
|
document.querySelector<HTMLElement>('#name')?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === STEP.NAME) {
|
if (step === 'NAME') {
|
||||||
setStep(STEP.SIGN);
|
setStep('SIGN');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.querySelector<HTMLElement>('#signatureText')?.focus();
|
document.querySelector<HTMLElement>('#signatureText')?.focus();
|
||||||
@@ -231,7 +226,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="bg-primary h-full w-14 rounded"
|
className="bg-primary h-full w-14 rounded"
|
||||||
disabled={!field.value || !!errors.email?.message}
|
disabled={!field.value || !!errors.email?.message}
|
||||||
onClick={() => step === STEP.EMAIL && onNextStepClick()}
|
onClick={() => step === 'EMAIL' && onNextStepClick()}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
@@ -243,7 +238,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
<FormErrorMessage error={errors.email} className="mt-1" />
|
<FormErrorMessage error={errors.email} className="mt-1" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{(step === STEP.NAME || step === STEP.SIGN) && (
|
{(step === 'NAME' || step === 'SIGN') && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="name"
|
key="name"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export const STEP = {
|
|
||||||
EMAIL: 'EMAIL',
|
|
||||||
NAME: 'NAME',
|
|
||||||
SIGN: 'SIGN',
|
|
||||||
} as const;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { buffer } from 'micro';
|
import { buffer } from 'micro';
|
||||||
@@ -6,8 +6,7 @@ import { buffer } from 'micro';
|
|||||||
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
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 { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
import { redis } from '@documenso/lib/server-only/redis';
|
||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import { Stripe, 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 { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ import { appRouter } from '@documenso/trpc/server/router';
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
maxDuration: 60,
|
maxDuration: 60,
|
||||||
api: {
|
|
||||||
bodyParser: {
|
|
||||||
sizeLimit: '50mb',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default trpcNext.createNextApiHandler({
|
export default trpcNext.createNextApiHandler({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
"**/*.mjs",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ const config = {
|
|||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverActions: {
|
serverActionsBodySizeLimit: '50mb',
|
||||||
bodySizeLimit: '50mb',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "1.2.3",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"e2e:prepare": "next build && next start",
|
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules",
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
@@ -28,8 +27,8 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.3",
|
"next": "14.0.0",
|
||||||
"next-auth": "4.24.5",
|
"next-auth": "4.24.3",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
@@ -42,10 +41,9 @@
|
|||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"sharp": "0.33.1",
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"uqr": "^0.1.2",
|
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -54,13 +52,5 @@
|
|||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"next-auth": {
|
|
||||||
"next": "$next"
|
|
||||||
},
|
|
||||||
"next-contentlayer": {
|
|
||||||
"next": "$next"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/web/process-env.d.ts
vendored
2
apps/web/process-env.d.ts
vendored
@@ -6,6 +6,8 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: 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_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { z } from 'zod';
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
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">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<MultiSelectCombobox
|
<Combobox
|
||||||
listValues={roles}
|
listValues={roles}
|
||||||
onChange={(values: string[]) => onChange(values)}
|
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 { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Document, Role, Subscription } from '@documenso/prisma/client';
|
import { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@@ -19,7 +19,7 @@ type UserData = {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
Subscription?: SubscriptionLite[] | null;
|
Subscription?: SubscriptionLite | null;
|
||||||
Document: DocumentLite[];
|
Document: DocumentLite[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,16 +35,9 @@ type UsersDataTableProps = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
individualPriceIds: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UsersDataTable = ({
|
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
|
||||||
users,
|
|
||||||
totalPages,
|
|
||||||
perPage,
|
|
||||||
page,
|
|
||||||
individualPriceIds,
|
|
||||||
}: UsersDataTableProps) => {
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const [searchString, setSearchString] = useState('');
|
const [searchString, setSearchString] = useState('');
|
||||||
@@ -107,13 +100,7 @@ export const UsersDataTable = ({
|
|||||||
{
|
{
|
||||||
header: 'Subscription',
|
header: 'Subscription',
|
||||||
accessorKey: 'subscription',
|
accessorKey: 'subscription',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
|
||||||
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
|
|
||||||
individualPriceIds.includes(sub.priceId),
|
|
||||||
);
|
|
||||||
|
|
||||||
return foundIndividualSubscription?.status ?? 'NONE';
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Documents',
|
header: 'Documents',
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
|
||||||
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||||
|
|
||||||
export async function search(search: string, page: number, perPage: number) {
|
export async function search(search: string, page: number, perPage: number) {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
if (!isAdmin(user)) {
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await findUsers({ username: search, email: search, page, perPage });
|
const results = await findUsers({ username: search, email: search, page, perPage });
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
|
||||||
|
|
||||||
import { UsersDataTable } from './data-table-users';
|
import { UsersDataTable } from './data-table-users';
|
||||||
import { search } from './fetch-users.actions';
|
import { search } from './fetch-users.actions';
|
||||||
|
|
||||||
@@ -16,23 +14,12 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
|||||||
const perPage = Number(searchParams.perPage) || 10;
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
const searchString = searchParams.search || '';
|
const searchString = searchParams.search || '';
|
||||||
|
|
||||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
const { users, totalPages } = await search(searchString, page, perPage);
|
||||||
search(searchString, page, perPage),
|
|
||||||
getPricesByType('individual'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const individualPriceIds = individualPrices.map((price) => price.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage users</h2>
|
<h2 className="text-4xl font-semibold">Manage users</h2>
|
||||||
<UsersDataTable
|
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
|
||||||
users={users}
|
|
||||||
individualPriceIds={individualPriceIds}
|
|
||||||
totalPages={totalPages}
|
|
||||||
page={page}
|
|
||||||
perPage={perPage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,27 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
|
import {
|
||||||
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
|
DocumentFlowFormContainer,
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
DocumentFlowFormContainerHeader,
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { Comments } from '~/components/forms/comments';
|
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
||||||
|
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
||||||
|
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -35,8 +35,7 @@ export type EditDocumentFormProps = {
|
|||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||||
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
|
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
@@ -49,60 +48,29 @@ export const EditDocumentForm = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// controlled stepper state
|
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||||
const [step, setStep] = useState<EditDocumentStep>(
|
|
||||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
|
||||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
|
||||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
title: {
|
|
||||||
title: 'Add Title',
|
|
||||||
description: 'Add the title to the document.',
|
|
||||||
stepIndex: 1,
|
|
||||||
},
|
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Signers',
|
title: 'Add Signers',
|
||||||
description: 'Add the people who will sign the document.',
|
description: 'Add the people who will sign the document.',
|
||||||
stepIndex: 2,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: 'Add Fields',
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: 'Add all relevant fields for each recipient.',
|
||||||
stepIndex: 3,
|
stepIndex: 2,
|
||||||
|
onBackStep: () => setStep('signers'),
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
title: 'Add Subject',
|
title: 'Add Subject',
|
||||||
description: 'Add the subject and message you wish to send to signers.',
|
description: 'Add the subject and message you wish to send to signers.',
|
||||||
stepIndex: 4,
|
stepIndex: 3,
|
||||||
|
onBackStep: () => setStep('fields'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
const currentDocumentFlow = documentFlow[step];
|
||||||
try {
|
|
||||||
// Custom invocation server action
|
|
||||||
await addTitle({
|
|
||||||
documentId: document.id,
|
|
||||||
title: data.title,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
|
|
||||||
setStep('signers');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'An error occurred while updating title.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
@@ -113,6 +81,7 @@ export const EditDocumentForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -134,6 +103,7 @@ export const EditDocumentForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('subject');
|
setStep('subject');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -147,16 +117,14 @@ export const EditDocumentForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
const { subject, message, timezone, dateFormat } = data.meta;
|
const { subject, message } = data.email;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendDocument({
|
await completeDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
meta: {
|
email: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
timezone,
|
|
||||||
dateFormat,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,10 +146,7 @@ export const EditDocumentForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
<Card
|
<Card
|
||||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
@@ -193,58 +158,46 @@ export const EditDocumentForm = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<DocumentFlowFormContainer
|
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||||
className="lg:h-[calc(100vh-6rem)]"
|
<DocumentFlowFormContainerHeader
|
||||||
onSubmit={(e) => e.preventDefault()}
|
title={currentDocumentFlow.title}
|
||||||
>
|
description={currentDocumentFlow.description}
|
||||||
<Stepper
|
|
||||||
currentStep={currentDocumentFlow.stepIndex}
|
|
||||||
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
|
||||||
>
|
|
||||||
<AddTitleFormPartial
|
|
||||||
key={recipients.length}
|
|
||||||
documentFlow={documentFlow.title}
|
|
||||||
recipients={recipients}
|
|
||||||
fields={fields}
|
|
||||||
document={document}
|
|
||||||
onSubmit={onAddTitleFormSubmit}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{step === 'signers' && (
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
document={document}
|
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'fields' && (
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
key={fields.length}
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'subject' && (
|
||||||
<AddSubjectFormPartial
|
<AddSubjectFormPartial
|
||||||
key={recipients.length}
|
|
||||||
documentFlow={documentFlow.subject}
|
documentFlow={documentFlow.subject}
|
||||||
document={document}
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onSubmit={onAddSubjectFormSubmit}
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
)}
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Card className="my-8" gradient={true} degrees={200}>
|
|
||||||
<CardContent className="mt-8 flex flex-col">
|
|
||||||
<h2 className="text-foreground text-2xl font-semibold">Comments</h2>
|
|
||||||
<hr className="border-border mb-4 mt-4" />
|
|
||||||
<Comments />
|
|
||||||
<hr className="border-border -mt-4 mb-4" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
const { documentData } = document;
|
const { documentData } = document;
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
const [recipients, fields] = await Promise.all([
|
||||||
getRecipientsForDocument({
|
await getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}),
|
}),
|
||||||
getFieldsForDocument({
|
await getFieldsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
|||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type DataTableActionButtonProps = {
|
export type DataTableActionButtonProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@@ -23,7 +22,6 @@ export type DataTableActionButtonProps = {
|
|||||||
|
|
||||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@@ -39,7 +37,6 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
@@ -73,13 +70,6 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
window.URL.revokeObjectURL(link.href);
|
window.URL.revokeObjectURL(link.href);
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'An error occurred while trying to download file.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
||||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
@@ -60,7 +60,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
// const isPending = row.status === DocumentStatus.PENDING;
|
// const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isDocumentDeletable = isOwner;
|
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
@@ -99,7 +99,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
@@ -162,10 +161,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
{isDocumentDeletable && (
|
||||||
<DeleteDocumentDialog
|
<DeleteDraftDocumentDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
status={row.status}
|
|
||||||
documentTitle={row.title}
|
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
@@ -75,9 +74,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => (
|
||||||
(!row.original.deletedAt ||
|
|
||||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DataTableActionButton row={row.original} />
|
<DataTableActionButton row={row.original} />
|
||||||
<DataTableActionDropdown row={row.original} />
|
<DataTableActionDropdown row={row.original} />
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
type DeleteDraftDocumentDialogProps = {
|
|
||||||
id: number;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
status: DocumentStatus;
|
|
||||||
documentTitle: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DeleteDocumentDialog = ({
|
|
||||||
id,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
status,
|
|
||||||
documentTitle,
|
|
||||||
}: DeleteDraftDocumentDialogProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
router.refresh();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Document deleted',
|
|
||||||
description: `"${documentTitle}" has been successfully deleted`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setInputValue('');
|
|
||||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
|
||||||
}
|
|
||||||
}, [open, status]);
|
|
||||||
|
|
||||||
const onDelete = async () => {
|
|
||||||
try {
|
|
||||||
await deleteDocument({ id, status });
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'This document could not be deleted at this time. Please try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 7500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setInputValue(event.target.value);
|
|
||||||
setIsDeleteEnabled(event.target.value === 'delete');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
Please note that this action is irreversible. Once confirmed, your document will be
|
|
||||||
permanently deleted.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{status !== DocumentStatus.DRAFT && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={onInputChange}
|
|
||||||
placeholder="Type 'delete' to confirm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
loading={isLoading}
|
|
||||||
onClick={onDelete}
|
|
||||||
disabled={!isDeleteEnabled}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -12,24 +12,29 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type DeleteTemplateDialogProps = {
|
type DeleteDraftDocumentDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
|
export const DeleteDraftDocumentDialog = ({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DeleteDraftDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
|
const { mutateAsync: deleteDocument, isLoading } =
|
||||||
|
trpcReact.document.deleteDraftDocument.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Template deleted',
|
title: 'Document deleted',
|
||||||
description: 'Your template has been successfully deleted.',
|
description: 'Your document has been successfully deleted.',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,13 +42,13 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteTemplate = async () => {
|
const onDraftDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteTemplate({ id });
|
await deleteDocument({ id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
description: 'This template could not be deleted at this time. Please try again.',
|
description: 'This document could not be deleted at this time. Please try again.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@@ -54,10 +59,10 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Do you want to delete this template?</DialogTitle>
|
<DialogTitle>Do you want to delete this document?</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Please note that this action is irreversible. Once confirmed, your template will be
|
Please note that this action is irreversible. Once confirmed, your document will be
|
||||||
permanently deleted.
|
permanently deleted.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -73,7 +78,7 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
|
<Button type="button" loading={isLoading} onClick={onDraftDelete} className="flex-1">
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +41,6 @@ export const DuplicateDocumentDialog = ({
|
|||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`/documents/${newId}`);
|
router.push(`/documents/${newId}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
description: 'Your document has been successfully duplicated.',
|
description: 'Your document has been successfully duplicated.',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -9,7 +9,6 @@ import { Loader } from 'lucide-react';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
@@ -24,8 +23,6 @@ export type UploadDocumentProps = {
|
|||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const analytics = useAnalytics();
|
|
||||||
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -36,16 +33,6 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
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) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -68,12 +55,6 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Document Uploaded', {
|
|
||||||
userId: session?.user.id,
|
|
||||||
documentId: id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`/documents/${id}`);
|
router.push(`/documents/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -101,7 +82,6 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
className="min-h-[40vh]"
|
className="min-h-[40vh]"
|
||||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|||||||
@@ -1,13 +1,46 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import {
|
||||||
|
getStripeCustomerByEmail,
|
||||||
|
getStripeCustomerById,
|
||||||
|
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-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 () => {
|
export const createBillingPortal = async () => {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const { stripeCustomer } = await getStripeCustomerByUser(user);
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return getPortalSession({
|
return getPortalSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
|
|||||||
@@ -1,36 +1,55 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import {
|
||||||
|
getStripeCustomerByEmail,
|
||||||
|
getStripeCustomerById,
|
||||||
|
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
|
||||||
export type CreateCheckoutOptions = {
|
export type CreateCheckoutOptions = {
|
||||||
priceId: string;
|
priceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||||
const session = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const { user, stripeCustomer } = await getStripeCustomerByUser(session.user);
|
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||||
|
|
||||||
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
|
let stripeCustomer: Stripe.Customer | null = null;
|
||||||
|
|
||||||
const foundSubscription = existingSubscriptions.find(
|
// Find the Stripe customer for the current user subscription.
|
||||||
(subscription) =>
|
if (existingSubscription) {
|
||||||
subscription.priceId === priceId &&
|
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
||||||
subscription.periodEnd &&
|
|
||||||
subscription.periodEnd >= new Date(),
|
if (!stripeCustomer) {
|
||||||
);
|
throw new Error('Missing Stripe customer for subscription');
|
||||||
|
}
|
||||||
|
|
||||||
if (foundSubscription) {
|
|
||||||
return getPortalSession({
|
return getPortalSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
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({
|
return getCheckoutSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
priceId,
|
priceId,
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { match } from 'ts-pattern';
|
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 { 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 { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
@@ -18,7 +15,7 @@ import { BillingPlans } from './billing-plans';
|
|||||||
import { BillingPortalButton } from './billing-portal-button';
|
import { BillingPortalButton } from './billing-portal-button';
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
let { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||||
|
|
||||||
@@ -27,40 +24,24 @@ export default async function BillingSettingsPage() {
|
|||||||
redirect('/settings/profile');
|
redirect('/settings/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.customerId) {
|
const [subscription, prices] = await Promise.all([
|
||||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
getSubscriptionByUserId({ userId: user.id }),
|
||||||
}
|
getPricesByInterval(),
|
||||||
|
|
||||||
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;
|
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) {
|
if (subscription?.priceId) {
|
||||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
() => null,
|
() => null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMissingOrInactiveOrFreePlan =
|
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
||||||
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">Billing</h3>
|
<h3 className="text-lg font-medium">Billing</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
export default function PasswordSettingsPage() {
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
redirect('/settings/security');
|
|
||||||
|
export default async function PasswordSettingsPage() {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Password</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<PasswordForm user={user} className="max-w-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
<h3 className="text-lg font-medium">Profile</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
|
|
||||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
|
||||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
|
||||||
|
|
||||||
export default async function SecuritySettingsPage() {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold">Security</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
Here you can manage your password and security settings.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
|
||||||
|
|
||||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
Add and manage your two factor security settings to add an extra layer of security to your
|
|
||||||
account!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 max-w-xl">
|
|
||||||
<h5 className="font-medium">Two-factor methods</h5>
|
|
||||||
|
|
||||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user.twoFactorEnabled && (
|
|
||||||
<div className="mt-4 max-w-xl">
|
|
||||||
<h5 className="font-medium">Recovery methods</h5>
|
|
||||||
|
|
||||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import Link from 'next/link';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { CheckCircle2, Clock8 } from 'lucide-react';
|
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||||
import { getServerSession } from 'next-auth';
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
@@ -15,8 +14,6 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume
|
|||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -38,8 +35,6 @@ export default async function CompletedSigningPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
|
||||||
|
|
||||||
const { documentData } = document;
|
const { documentData } = document;
|
||||||
|
|
||||||
const [fields, recipient] = await Promise.all([
|
const [fields, recipient] = await Promise.all([
|
||||||
@@ -58,9 +53,6 @@ export default async function CompletedSigningPage({
|
|||||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||||
recipient.email;
|
recipient.email;
|
||||||
|
|
||||||
const sessionData = await getServerSession();
|
|
||||||
const isLoggedIn = !!sessionData?.user;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
||||||
{/* Card with recipient */}
|
{/* Card with recipient */}
|
||||||
@@ -71,46 +63,34 @@ export default async function CompletedSigningPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative mt-6 flex w-full flex-col items-center">
|
<div className="relative mt-6 flex w-full flex-col items-center">
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
{match(document.status)
|
||||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<div className="text-documenso-700 flex items-center text-center">
|
<div className="text-documenso-700 flex items-center text-center">
|
||||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||||
<span className="text-sm">Everyone has signed</span>
|
<span className="text-sm">Everyone has signed</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
.with({ deletedAt: null }, () => (
|
.otherwise(() => (
|
||||||
<div className="flex items-center text-center text-blue-600">
|
<div className="flex items-center text-center text-blue-600">
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
<span className="text-sm">Waiting for others to sign</span>
|
<span className="text-sm">Waiting for others to sign</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<div className="flex items-center text-center text-red-600">
|
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
|
||||||
<span className="text-sm">Document no longer available to sign</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
You have signed
|
You have signed
|
||||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
<span className="mt-1.5 block">"{document.title}"</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
{match(document.status)
|
||||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
Everyone has signed! You will receive an Email copy of the signed document.
|
Everyone has signed! You will receive an Email copy of the signed document.
|
||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
.with({ deletedAt: null }, () => (
|
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
|
||||||
You will receive an Email copy of the signed document once everyone has signed.
|
|
||||||
</p>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
This document has been cancelled by the owner and is no longer available for others to
|
You will receive an Email copy of the signed document once everyone has signed.
|
||||||
sign.
|
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -125,11 +105,6 @@ export default async function CompletedSigningPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoggedIn ? (
|
|
||||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
|
||||||
Go Back Home
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||||
Want to send slick signing links like this one?{' '}
|
Want to send slick signing links like this one?{' '}
|
||||||
<Link
|
<Link
|
||||||
@@ -139,7 +114,6 @@ export default async function CompletedSigningPage({
|
|||||||
Check out Documenso.
|
Check out Documenso.
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,13 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -21,16 +16,9 @@ import { SigningFieldContainer } from './signing-field-container';
|
|||||||
export type DateFieldProps = {
|
export type DateFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
dateFormat?: string | null;
|
|
||||||
timezone?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DateField = ({
|
export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||||
field,
|
|
||||||
recipient,
|
|
||||||
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
|
||||||
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
|
||||||
}: DateFieldProps) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -47,18 +35,12 @@ export const DateField = ({
|
|||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
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 () => {
|
const onSign = async () => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
value: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
@@ -93,13 +75,7 @@ export const DateField = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
field={field}
|
|
||||||
onSign={onSign}
|
|
||||||
onRemove={onRemove}
|
|
||||||
type="Date"
|
|
||||||
tooltipText={isDifferentTime ? tooltipText : undefined}
|
|
||||||
>
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<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" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@@ -111,7 +87,7 @@ export const DateField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
|
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
|
||||||
)}
|
)}
|
||||||
</SigningFieldContainer>
|
</SigningFieldContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<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" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -30,16 +29,11 @@ export type SigningFormProps = {
|
|||||||
|
|
||||||
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const analytics = useAnalytics();
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken } =
|
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
@@ -49,11 +43,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
const fieldsValidated = () => {
|
|
||||||
setValidateUninsertedFields(true);
|
|
||||||
validateFieldsInserted(fields);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
@@ -68,12 +57,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
|
||||||
signerId: recipient.id,
|
|
||||||
documentId: document.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`/sign/${recipient.token}/complete`);
|
router.push(`/sign/${recipient.token}/complete`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,11 +81,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||||
>
|
>
|
||||||
<div
|
<div className={cn('flex flex-1 flex-col')}>
|
||||||
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>
|
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
@@ -159,7 +138,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
document={document}
|
document={document}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
@@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<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" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Clock8 } from 'lucide-react';
|
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
|
||||||
import type { Document, Signature } from '@documenso/prisma/client';
|
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
|
||||||
|
|
||||||
type NoLongerAvailableProps = {
|
|
||||||
document: Document;
|
|
||||||
recipientName: string;
|
|
||||||
recipientSignature: Signature;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NoLongerAvailable = ({
|
|
||||||
document,
|
|
||||||
recipientName,
|
|
||||||
recipientSignature,
|
|
||||||
}: NoLongerAvailableProps) => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
|
||||||
<SigningCard3D
|
|
||||||
name={recipientName}
|
|
||||||
signature={recipientSignature}
|
|
||||||
signingCelebrationImage={signingCelebration}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative mt-2 flex w-full flex-col items-center">
|
|
||||||
<div className="mt-8 flex items-center text-center text-red-600">
|
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
|
||||||
<span className="text-sm">Document Cancelled</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
|
||||||
<span className="mt-1.5 block">"{document.title}"</span>
|
|
||||||
is no longer available to sign
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
|
||||||
This document has been cancelled by the owner.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{session?.user ? (
|
|
||||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
|
||||||
Go Back Home
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
|
||||||
Want to send slick signing links like this one?{' '}
|
|
||||||
<Link
|
|
||||||
href="https://documenso.com"
|
|
||||||
className="text-documenso-700 hover:text-documenso-600"
|
|
||||||
>
|
|
||||||
Check out Documenso.
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,28 +2,21 @@ import { notFound, redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { match } from 'ts-pattern';
|
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 { 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 { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
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 { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
import { EmailField } from './email-field';
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
import { NameField } from './name-field';
|
import { NameField } from './name-field';
|
||||||
import { NoLongerAvailable } from './no-longer-available';
|
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
import { SignatureField } from './signature-field';
|
import { SignatureField } from './signature-field';
|
||||||
|
|
||||||
@@ -47,14 +40,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
viewedDocument({ token }).catch(() => null),
|
viewedDocument({ token }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
|
|
||||||
|
|
||||||
if (!document || !document.documentData || !recipient) {
|
if (!document || !document.documentData || !recipient) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
|
||||||
|
|
||||||
const { documentData } = document;
|
const { documentData } = document;
|
||||||
|
|
||||||
const { user } = await getServerComponentSession();
|
const { user } = await getServerComponentSession();
|
||||||
@@ -66,18 +55,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
redirect(`/sign/${token}/complete`);
|
redirect(`/sign/${token}/complete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
|
|
||||||
|
|
||||||
if (document.deletedAt) {
|
|
||||||
return (
|
|
||||||
<NoLongerAvailable
|
|
||||||
document={document}
|
|
||||||
recipientName={recipient.name}
|
|
||||||
recipientSignature={recipientSignature}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningProvider
|
<SigningProvider
|
||||||
email={recipient.email}
|
email={recipient.email}
|
||||||
@@ -86,7 +63,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
<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}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
{truncatedTitle}
|
{document.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
@@ -120,13 +97,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
<NameField key={field.id} field={field} recipient={recipient} />
|
<NameField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
.with(FieldType.DATE, () => (
|
.with(FieldType.DATE, () => (
|
||||||
<DateField
|
<DateField key={field.id} field={field} recipient={recipient} />
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
recipient={recipient}
|
|
||||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
|
||||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
.with(FieldType.EMAIL, () => (
|
.with(FieldType.EMAIL, () => (
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { Document, Field } from '@documenso/prisma/client';
|
import { Document, Field } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -9,13 +9,10 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
|
||||||
|
|
||||||
export type SignDialogProps = {
|
export type SignDialogProps = {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
document: Document;
|
document: Document;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,31 +20,30 @@ export const SignDialog = ({
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
document,
|
document,
|
||||||
fields,
|
fields,
|
||||||
fieldsValidated,
|
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
}: SignDialogProps) => {
|
}: SignDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
|
||||||
const isComplete = fields.every((field) => field.inserted);
|
const isComplete = fields.every((field) => field.inserted);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={fieldsValidated}
|
disabled={!isComplete}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{isComplete ? 'Complete' : 'Next field'}
|
Complete
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
|
<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">
|
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||||
You are about to finish signing "{truncatedTitle}". Are you sure?
|
You are about to finish signing "{document.title}". Are you sure?
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
@@ -76,16 +76,10 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value,
|
value: source === 'local' && localSignature ? localSignature : providedSignature ?? '',
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +121,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<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" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
|
||||||
|
|
||||||
export type SignatureFieldProps = {
|
export type SignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
@@ -12,8 +11,6 @@ export type SignatureFieldProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSign?: () => Promise<void> | void;
|
onSign?: () => Promise<void> | void;
|
||||||
onRemove?: () => Promise<void> | void;
|
onRemove?: () => Promise<void> | void;
|
||||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
|
||||||
tooltipText?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningFieldContainer = ({
|
export const SigningFieldContainer = ({
|
||||||
@@ -22,8 +19,6 @@ export const SigningFieldContainer = ({
|
|||||||
onSign,
|
onSign,
|
||||||
onRemove,
|
onRemove,
|
||||||
children,
|
children,
|
||||||
type,
|
|
||||||
tooltipText,
|
|
||||||
}: SignatureFieldProps) => {
|
}: SignatureFieldProps) => {
|
||||||
const onSignFieldClick = async () => {
|
const onSignFieldClick = async () => {
|
||||||
if (field.inserted) {
|
if (field.inserted) {
|
||||||
@@ -51,22 +46,7 @@ export const SigningFieldContainer = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'Date' && field.inserted && !loading && (
|
{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
|
<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"
|
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}
|
onClick={onRemoveSignedFieldClick}
|
||||||
|
|||||||
@@ -13,14 +13,12 @@ export default function SignInPage() {
|
|||||||
|
|
||||||
<SignInForm className="mt-4" />
|
<SignInForm className="mt-4" />
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-2.5 text-center">
|
<p className="mt-2.5 text-center">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
|
||||||
redirect('/signin');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||||
|
|||||||
@@ -69,7 +69,15 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Waiting</h1>
|
<h1 className="text-base font-medium">Waiting</h1>
|
||||||
{waitingRecipients.map((recipient: Recipient) => (
|
{waitingRecipients.map((recipient: Recipient) => (
|
||||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||||
|
<StackAvatar
|
||||||
|
first={true}
|
||||||
|
key={recipient.id}
|
||||||
|
type={getRecipientType(recipient)}
|
||||||
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DOCUMENTS_PAGE_SHORTCUT,
|
DOCUMENTS_PAGE_SHORTCUT,
|
||||||
SETTINGS_PAGE_SHORTCUT,
|
SETTINGS_PAGE_SHORTCUT,
|
||||||
TEMPLATES_PAGE_SHORTCUT,
|
|
||||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -23,7 +21,6 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
} from '@documenso/ui/primitives/command';
|
} from '@documenso/ui/primitives/command';
|
||||||
import { THEMES_TYPE } from '@documenso/ui/primitives/constants';
|
|
||||||
|
|
||||||
const DOCUMENTS_PAGES = [
|
const DOCUMENTS_PAGES = [
|
||||||
{
|
{
|
||||||
@@ -32,28 +29,13 @@ const DOCUMENTS_PAGES = [
|
|||||||
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
||||||
},
|
},
|
||||||
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
|
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
|
||||||
{
|
{ label: 'Completed documents', path: '/documents?status=COMPLETED' },
|
||||||
label: 'Completed documents',
|
|
||||||
path: '/documents?status=COMPLETED',
|
|
||||||
},
|
|
||||||
{ label: 'Pending documents', path: '/documents?status=PENDING' },
|
{ label: 'Pending documents', path: '/documents?status=PENDING' },
|
||||||
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TEMPLATES_PAGES = [
|
|
||||||
{
|
|
||||||
label: 'All templates',
|
|
||||||
path: '/templates',
|
|
||||||
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SETTINGS_PAGES = [
|
const SETTINGS_PAGES = [
|
||||||
{
|
{ label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') },
|
||||||
label: 'Settings',
|
|
||||||
path: '/settings',
|
|
||||||
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
|
|
||||||
},
|
|
||||||
{ label: 'Profile', path: '/settings/profile' },
|
{ label: 'Profile', path: '/settings/profile' },
|
||||||
{ label: 'Password', path: '/settings/password' },
|
{ label: 'Password', path: '/settings/password' },
|
||||||
];
|
];
|
||||||
@@ -71,32 +53,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [pages, setPages] = useState<string[]>([]);
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
|
|
||||||
trpcReact.document.searchDocuments.useQuery(
|
|
||||||
{
|
|
||||||
query: search,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
|
||||||
if (!searchDocumentsData) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchDocumentsData.map((document) => ({
|
|
||||||
label: document.title,
|
|
||||||
path: `/documents/${document.id}`,
|
|
||||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
|
||||||
}));
|
|
||||||
}, [searchDocumentsData]);
|
|
||||||
|
|
||||||
const currentPage = pages[pages.length - 1];
|
const currentPage = pages[pages.length - 1];
|
||||||
|
|
||||||
const toggleOpen = (e: KeyboardEvent) => {
|
const toggleOpen = () => {
|
||||||
e.preventDefault();
|
|
||||||
setIsOpen((isOpen) => !isOpen);
|
setIsOpen((isOpen) => !isOpen);
|
||||||
onOpenChange?.(!isOpen);
|
onOpenChange?.(!isOpen);
|
||||||
|
|
||||||
@@ -134,12 +93,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
|
|
||||||
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
||||||
const goToDocuments = useCallback(() => push(DOCUMENTS_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);
|
||||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||||
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||||
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
// Escape goes to previous page
|
// Escape goes to previous page
|
||||||
@@ -156,13 +113,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog
|
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}>
|
||||||
commandProps={{
|
|
||||||
onKeyDown: handleKeyDown,
|
|
||||||
}}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
>
|
|
||||||
<CommandInput
|
<CommandInput
|
||||||
value={search}
|
value={search}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
@@ -170,36 +121,18 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{isSearchingDocuments ? (
|
|
||||||
<CommandEmpty>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<span className="animate-spin">
|
|
||||||
<Loader />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CommandEmpty>
|
|
||||||
) : (
|
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
)}
|
|
||||||
{!currentPage && (
|
{!currentPage && (
|
||||||
<>
|
<>
|
||||||
<CommandGroup heading="Documents">
|
<CommandGroup heading="Documents">
|
||||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Templates">
|
|
||||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
|
||||||
</CommandGroup>
|
|
||||||
<CommandGroup heading="Settings">
|
<CommandGroup heading="Settings">
|
||||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Preferences">
|
<CommandGroup heading="Preferences">
|
||||||
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{searchResults.length > 0 && (
|
|
||||||
<CommandGroup heading="Your documents">
|
|
||||||
<Commands push={push} pages={searchResults} />
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
||||||
@@ -213,14 +146,10 @@ const Commands = ({
|
|||||||
pages,
|
pages,
|
||||||
}: {
|
}: {
|
||||||
push: (_path: string) => void;
|
push: (_path: string) => void;
|
||||||
pages: { label: string; path: string; shortcut?: string; value?: string }[];
|
pages: { label: string; path: string; shortcut?: string }[];
|
||||||
}) => {
|
}) => {
|
||||||
return pages.map((page, idx) => (
|
return pages.map((page) => (
|
||||||
<CommandItem
|
<CommandItem key={page.path} onSelect={() => push(page.path)}>
|
||||||
key={page.path + idx}
|
|
||||||
value={page.value ?? page.label}
|
|
||||||
onSelect={() => push(page.path)}
|
|
||||||
>
|
|
||||||
{page.label}
|
{page.label}
|
||||||
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -230,9 +159,9 @@ const Commands = ({
|
|||||||
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
|
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
|
||||||
const THEMES = useMemo(
|
const THEMES = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: 'Light Mode', theme: THEMES_TYPE.LIGHT, icon: Sun },
|
{ label: 'Light Mode', theme: 'light', icon: Sun },
|
||||||
{ label: 'Dark Mode', theme: THEMES_TYPE.DARK, icon: Moon },
|
{ label: 'Dark Mode', theme: 'dark', icon: Moon },
|
||||||
{ label: 'System Theme', theme: THEMES_TYPE.SYSTEM, icon: Monitor },
|
{ label: 'System Theme', theme: 'system', icon: Monitor },
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -13,22 +10,10 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
|
|
||||||
import { CommandMenu } from '../common/command-menu';
|
import { CommandMenu } from '../common/command-menu';
|
||||||
|
|
||||||
const navigationLinks = [
|
|
||||||
{
|
|
||||||
href: '/documents',
|
|
||||||
label: 'Documents',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/templates',
|
|
||||||
label: 'Templates',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
const pathname = usePathname();
|
// const pathname = usePathname();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||||
|
|
||||||
@@ -41,29 +26,9 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn('ml-8 hidden flex-1 gap-x-6 md:flex md:justify-center', className)}
|
||||||
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...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} />
|
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -82,6 +47,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import { User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Logo } from '~/components/branding/logo';
|
import { Logo } from '~/components/branding/logo';
|
||||||
@@ -33,7 +32,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
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',
|
scrollY > 5 && 'border-b-border',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -49,7 +48,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
|
|
||||||
<DesktopNav />
|
<DesktopNav />
|
||||||
|
|
||||||
<div className="flex gap-x-4 md:ml-8">
|
<div className="flex gap-x-4">
|
||||||
<ProfileDropdown user={user} />
|
<ProfileDropdown user={user} />
|
||||||
|
|
||||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileSpreadsheet,
|
Key,
|
||||||
Lock,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
User as LucideUser,
|
User as LucideUser,
|
||||||
Monitor,
|
Monitor,
|
||||||
@@ -21,7 +20,7 @@ import { LuGithub } from 'react-icons/lu';
|
|||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { User } from '@documenso/prisma/client';
|
import { User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -57,11 +56,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
variant="ghost"
|
|
||||||
title="Profile Dropdown"
|
|
||||||
className="relative h-10 w-10 rounded-full"
|
|
||||||
>
|
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -92,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/security" className="cursor-pointer">
|
<Link href="/settings/password" className="cursor-pointer">
|
||||||
<Lock className="mr-2 h-4 w-4" />
|
<Key className="mr-2 h-4 w-4" />
|
||||||
Security
|
Password
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
@@ -107,13 +102,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/templates" className="cursor-pointer">
|
|
||||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
|
||||||
Templates
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/password">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Lock className="mr-2 h-5 w-5" />
|
<Key className="mr-2 h-5 w-5" />
|
||||||
Security
|
Password
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/password">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Lock className="mr-2 h-5 w-5" />
|
<Key className="mr-2 h-5 w-5" />
|
||||||
Security
|
Password
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
export type CommentCardProps = {
|
|
||||||
comment: any;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CommentCard = ({ comment, className }: CommentCardProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('mb-8', className)} key={comment.id}>
|
|
||||||
<p className="font-semibold">{comment.User.name}</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
<LocaleDate
|
|
||||||
date={comment.createdAt}
|
|
||||||
format={{
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<p className="mb-2 mt-2 text-base">{comment.comment}</p>
|
|
||||||
<Button>Reply</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
|
|
||||||
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
|
|
||||||
|
|
||||||
type AuthenticatorAppProps = {
|
|
||||||
isTwoFactorEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
|
|
||||||
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
|
|
||||||
|
|
||||||
const isEnableDialogOpen = modalState === 'enable';
|
|
||||||
const isDisableDialogOpen = modalState === 'disable';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
|
||||||
<div className="flex-1">
|
|
||||||
<p>Authenticator app</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
|
||||||
Create one-time passwords that serve as a secondary authentication method for confirming
|
|
||||||
your identity when requested during the sign-in process.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{isTwoFactorEnabled ? (
|
|
||||||
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
|
||||||
Disable 2FA
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={() => setModalState('enable')} size="sm">
|
|
||||||
Enable 2FA
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnableAuthenticatorAppDialog
|
|
||||||
key={isEnableDialogOpen ? 'open' : 'closed'}
|
|
||||||
open={isEnableDialogOpen}
|
|
||||||
onOpenChange={(open) => !open && setModalState(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DisableAuthenticatorAppDialog
|
|
||||||
key={isDisableDialogOpen ? 'open' : 'closed'}
|
|
||||||
open={isDisableDialogOpen}
|
|
||||||
onOpenChange={(open) => !open && setModalState(null)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { flushSync } from 'react-dom';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
|
||||||
password: z.string().min(6).max(72),
|
|
||||||
backupCode: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
|
||||||
typeof ZDisableTwoFactorAuthenticationForm
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type DisableAuthenticatorAppDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DisableAuthenticatorAppDialog = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: DisableAuthenticatorAppDialogProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: disableTwoFactorAuthentication } =
|
|
||||||
trpc.twoFactorAuthentication.disable.useMutation();
|
|
||||||
|
|
||||||
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
|
||||||
defaultValues: {
|
|
||||||
password: '',
|
|
||||||
backupCode: '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
|
||||||
disableTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
|
||||||
password,
|
|
||||||
backupCode,
|
|
||||||
}: TDisableTwoFactorAuthenticationForm) => {
|
|
||||||
try {
|
|
||||||
await disableTwoFactorAuthentication({ password, backupCode });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Two-factor authentication disabled',
|
|
||||||
description:
|
|
||||||
'Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.',
|
|
||||||
});
|
|
||||||
|
|
||||||
flushSync(() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to disable two-factor authentication',
|
|
||||||
description:
|
|
||||||
'We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Disable Authenticator App</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
To disable the Authenticator App for your account, please enter your password and a
|
|
||||||
backup code. If you do not have a backup code available, please contact support.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...disableTwoFactorAuthenticationForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
|
||||||
onDisableTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<fieldset
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
disabled={isDisableTwoFactorAuthenticationSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
name="password"
|
|
||||||
control={disableTwoFactorAuthenticationForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput
|
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="backupCode"
|
|
||||||
control={disableTwoFactorAuthenticationForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} type="text" value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDisableTwoFactorAuthenticationSubmitting}
|
|
||||||
>
|
|
||||||
Disable 2FA
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { flushSync } from 'react-dom';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { renderSVG } from 'uqr';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
|
||||||
|
|
||||||
export const ZSetupTwoFactorAuthenticationForm = z.object({
|
|
||||||
password: z.string().min(6).max(72),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
|
|
||||||
|
|
||||||
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
|
||||||
token: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
|
||||||
|
|
||||||
export type EnableAuthenticatorAppDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EnableAuthenticatorAppDialog = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: EnableAuthenticatorAppDialogProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
|
||||||
trpc.twoFactorAuthentication.setup.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
|
||||||
trpc.twoFactorAuthentication.enable.useMutation();
|
|
||||||
|
|
||||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
|
||||||
defaultValues: {
|
|
||||||
password: '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
|
||||||
setupTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
|
||||||
defaultValues: {
|
|
||||||
token: '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
|
||||||
enableTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const step = useMemo(() => {
|
|
||||||
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
|
|
||||||
return 'setup';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
|
|
||||||
return 'enable';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'view';
|
|
||||||
}, [
|
|
||||||
setupTwoFactorAuthenticationData,
|
|
||||||
isSetupTwoFactorAuthenticationSubmitting,
|
|
||||||
enableTwoFactorAuthenticationData,
|
|
||||||
isEnableTwoFactorAuthenticationSubmitting,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onSetupTwoFactorAuthenticationFormSubmit = async ({
|
|
||||||
password,
|
|
||||||
}: TSetupTwoFactorAuthenticationForm) => {
|
|
||||||
try {
|
|
||||||
await setupTwoFactorAuthentication({ password });
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to setup two-factor authentication',
|
|
||||||
description:
|
|
||||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
|
||||||
token,
|
|
||||||
}: TEnableTwoFactorAuthenticationForm) => {
|
|
||||||
try {
|
|
||||||
await enableTwoFactorAuthentication({ code: token });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Two-factor authentication enabled',
|
|
||||||
description:
|
|
||||||
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
|
|
||||||
});
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to setup two-factor authentication',
|
|
||||||
description:
|
|
||||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCompleteClick = () => {
|
|
||||||
flushSync(() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
|
||||||
|
|
||||||
{step === 'setup' && (
|
|
||||||
<DialogDescription>
|
|
||||||
To enable two-factor authentication, please enter your password below.
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'view' && (
|
|
||||||
<DialogDescription>
|
|
||||||
Your recovery codes are listed below. Please store them in a safe place.
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{match(step)
|
|
||||||
.with('setup', () => {
|
|
||||||
return (
|
|
||||||
<Form {...setupTwoFactorAuthenticationForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
|
|
||||||
onSetupTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
name="password"
|
|
||||||
control={setupTwoFactorAuthenticationForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput
|
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.with('enable', () => (
|
|
||||||
<Form {...enableTwoFactorAuthenticationForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
|
||||||
onEnableTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
To enable two-factor authentication, scan the following QR code using your
|
|
||||||
authenticator app.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex h-36 justify-center"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
If your authenticator app does not support QR codes, you can use the following
|
|
||||||
code instead:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
|
||||||
{setupTwoFactorAuthenticationData?.secret}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Once you have scanned the QR code or entered the code manually, enter the code
|
|
||||||
provided by your authenticator app below.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="token"
|
|
||||||
control={enableTwoFactorAuthenticationForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} type="text" value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
|
||||||
Enable 2FA
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
))
|
|
||||||
.with('view', () => (
|
|
||||||
<div>
|
|
||||||
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
|
||||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
|
||||||
<Button type="button" onClick={() => onCompleteClick()}>
|
|
||||||
Complete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Copy } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type RecoveryCodeListProps = {
|
|
||||||
recoveryCodes: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [, copyToClipboard] = useCopyToClipboard();
|
|
||||||
|
|
||||||
const onCopyRecoveryCodeClick = async (code: string) => {
|
|
||||||
try {
|
|
||||||
const result = await copyToClipboard(code);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new Error('Unable to copy recovery code');
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Recovery code copied',
|
|
||||||
description: 'Your recovery code has been copied to your clipboard.',
|
|
||||||
});
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to copy recovery code',
|
|
||||||
description:
|
|
||||||
'We were unable to copy your recovery code to your clipboard. Please try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{recoveryCodes.map((code) => (
|
|
||||||
<div
|
|
||||||
key={code}
|
|
||||||
className="bg-muted text-muted-foreground relative rounded-lg p-4 font-mono md:text-center"
|
|
||||||
>
|
|
||||||
<span>{code}</span>
|
|
||||||
|
|
||||||
<div className="absolute inset-y-0 right-4 flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
className="opacity-60 hover:opacity-80"
|
|
||||||
onClick={() => void onCopyRecoveryCodeClick(code)}
|
|
||||||
>
|
|
||||||
<Copy className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
|
||||||
|
|
||||||
type RecoveryCodesProps = {
|
|
||||||
// backupCodes: string[] | null;
|
|
||||||
isTwoFactorEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
|
||||||
<div className="flex-1">
|
|
||||||
<p>Recovery Codes</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
|
||||||
Recovery codes are used to access your account in the event that you lose access to your
|
|
||||||
authenticator app.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
|
||||||
View Codes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ViewRecoveryCodesDialog
|
|
||||||
key={isOpen ? 'open' : 'closed'}
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
|
||||||
|
|
||||||
export const ZViewRecoveryCodesForm = z.object({
|
|
||||||
password: z.string().min(6).max(72),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
|
||||||
|
|
||||||
export type ViewRecoveryCodesDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
|
||||||
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
|
||||||
|
|
||||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
|
||||||
defaultValues: {
|
|
||||||
password: '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
|
||||||
|
|
||||||
const step = useMemo(() => {
|
|
||||||
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
|
||||||
return 'authenticate';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'view';
|
|
||||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
|
||||||
|
|
||||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
|
||||||
try {
|
|
||||||
await viewRecoveryCodes({ password });
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to view recovery codes',
|
|
||||||
description:
|
|
||||||
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
|
||||||
|
|
||||||
{step === 'authenticate' && (
|
|
||||||
<DialogDescription>
|
|
||||||
To view your recovery codes, please enter your password below.
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'view' && (
|
|
||||||
<DialogDescription>
|
|
||||||
Your recovery codes are listed below. Please store them in a safe place.
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{match(step)
|
|
||||||
.with('authenticate', () => {
|
|
||||||
return (
|
|
||||||
<Form {...viewRecoveryCodesForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
name="password"
|
|
||||||
control={viewRecoveryCodesForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput
|
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.with('view', () => (
|
|
||||||
<div>
|
|
||||||
{viewRecoveryCodesData?.recoveryCodes && (
|
|
||||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
|
||||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { CornerDownRight } from 'lucide-react';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
|
|
||||||
import { CommentCard } from '~/components/comments/comment-card';
|
|
||||||
|
|
||||||
export const Comments = () => {
|
|
||||||
const { data: comments } = trpc.comment.getComments.useQuery();
|
|
||||||
|
|
||||||
console.log(comments);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{comments?.map((comment) => (
|
|
||||||
<div key={comment.id}>
|
|
||||||
<CommentCard comment={comment} className="mb-8" />
|
|
||||||
{comment.replies && comment.replies.length > 0 ? (
|
|
||||||
<div>
|
|
||||||
{comment.replies.map((reply) => (
|
|
||||||
<div className="ml-6 flex" key={reply.id}>
|
|
||||||
<CornerDownRight className="flex shrink-0" />
|
|
||||||
<CommentCard comment={reply} className="ml-6" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user