Compare commits

...

91 Commits

Author SHA1 Message Date
Adithya Krishna
39869c46a3 chore: team url slugify and profanity filter
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-27 18:57:38 +05:30
David Nguyen
d546907c53 feat: wip 2023-12-27 17:44:36 +11:00
David Nguyen
917a1271bf feat: wip 2023-12-27 17:32:56 +11:00
David Nguyen
ca703fc221 feat: wip 2023-12-27 16:05:05 +11:00
David Nguyen
f4309c1c83 feat: wip 2023-12-27 13:55:19 +11:00
David Nguyen
9d626473c8 feat: wip 2023-12-27 13:04:24 +11:00
Mohith Gadireddy
f7cf33c61b fix: Layout issue in the singleplayer mode (#759)
Fixes #744
2023-12-26 14:08:05 +11:00
Timur Ercan
8f4fea2f14 chore: user metrics naming, adopters description (#769)
Rename Metrics
2023-12-26 13:25:45 +11:00
Apoorv Taneja
5a32b5cafd fix: added constants for theme variables (#777)
fixes: #776
2023-12-26 10:49:27 +11:00
Lucas Smith
e9312ada51 feat: show document title for delete dialog - [DOC-387] (#772) 2023-12-22 15:09:03 +11:00
Lucas Smith
1c52c7ebcd chore: update copy 2023-12-22 03:43:12 +00:00
Lucas Smith
495cd35f7c refactor: forms (#697)
Updates our older forms to use the appropriate components bringing them in-line with the rest of our codebase.
2023-12-22 14:36:00 +11:00
JA
5a5d00fb2e fix(webapp): reset delete document dialog (#762)
This PR makes a small but useful tweak to the `DeleteDocumentDialog`.
Now, the input field gets cleared whenever the dialog is opened. Here’s
what’s changed:

1. **Clear Field After Deleting**: After you delete something and open
the dialog again, it won’t show the old, deleted text anymore. It’s
clean and ready for the next delete.

2. **Type Again to Confirm**: If you type something but close the dialog
without deleting, you’ll have to type it again next time. This way, it
makes sure the user really mean to delete something and didn't do it by
mistake.

Demo Link:
See the old vs. new in action here:
https://www.loom.com/share/80eca0d3b1994f7cbcab6f222db2dbfe?sid=ebc6135c-345e-4640-b395-daff190a96e7

It’s a small change, but it makes the delete process safer and smoother.
2023-12-22 14:14:33 +11:00
Lucas Smith
1aa0fc3101 fix: remove loadingText prop 2023-12-22 01:46:41 +00:00
Lucas Smith
48cdf43dcb feat: templates (#537)
Adds the basically ability to create and use templates for repetitive document types
2023-12-21 22:05:09 +11:00
Lucas Smith
1d9593dd0f fix: hotkeys was overlapping with the browser hotkeys (#774)
Issue - The `Ctrl+K` hotkey in our application is conflicting with the
browser's default search hotkey, leading to unintended browser actions.

https://github.com/documenso/documenso/assets/71957674/180b6028-58f7-4cf8-841c-1e13c9d4d355
2023-12-21 21:40:07 +11:00
Mythie
9ad94f9862 fix: updates from review 2023-12-21 21:37:33 +11:00
Mythie
972c20f906 chore: tidy migrations 2023-12-21 21:20:37 +11:00
harkiratsm
519c645d06 fix: hotkeys was overlapping with the browser hotkeys
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-21 15:36:24 +05:30
Mythie
7babd82470 fix: updates from review 2023-12-21 20:42:45 +11:00
Mythie
298396c86c fix: awaiting in promise.all array 2023-12-21 17:36:35 +11:00
Mythie
268a5c6508 fix: swap server-actions for trpc mutations 2023-12-21 17:01:12 +11:00
Lucas Smith
c40c9b20ec Merge branch 'main' into feat/document-templates 2023-12-21 14:25:22 +11:00
Adithya Krishna
84a0c39810 chore: made requested changes
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-20 10:36:06 +05:30
Adithya Krishna
1af909835d chore: updated title to double quotes
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-19 22:25:23 +05:30
Adithya Krishna
01caa949d9 feat: show document title for delete dialog
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-19 22:20:50 +05:30
Mythie
075fdd1f88 fix: lint errors 2023-12-16 15:09:02 +11:00
Lucas Smith
006a559026 Merge branch 'main' into refactor-forms 2023-12-16 13:26:33 +11:00
Lucas Smith
ff64671e49 fix: stacking issue with notification/toast and header (#764)
Fixes: #763
2023-12-16 12:40:25 +11:00
Apoorv Taneja
089ba1c30e Merge branch 'main' into fix/css-stacking-notification 2023-12-16 00:35:38 +05:30
apoorv taneja
cbcd893cfd fixed z-index 2023-12-16 00:30:52 +05:30
Apoorv Taneja
83dfe92d7a refactor: used constant for steps instead of strings (#751)
fixes #750
2023-12-15 15:07:45 +02:00
Lucas Smith
e43e8f8c4a feat: environment variable to toggle signups (#747)
closes #732
2023-12-15 23:09:01 +11:00
Mythie
82da337a56 fix: remove templateToken 2023-12-15 22:07:27 +11:00
Lucas Smith
6e10947d00 Merge branch 'main' into feat/732-toggle-signup-form 2023-12-15 21:05:21 +11:00
Lucas Smith
682cb37786 fix: update auth-options 2023-12-15 20:41:54 +11:00
Lucas Smith
5809480f02 chore: fix workflows and update package.json file (#758) 2023-12-15 16:55:48 +11:00
Mythie
1eeb5fb103 fix: tidy code and base on main 2023-12-14 15:28:27 +11:00
David Nguyen
88534fa1c6 feat: add multi subscription support (#734)
## Description

Previously we assumed that there can only be 1 subscription per user.
However, that will soon no longer the case with the introduction of the
Teams subscription.

This PR will apply the required migrations to support multiple
subscriptions.

## Changes Made

- Updated the Prisma schema to allow for multiple `Subscriptions` per
`User`
- Added a Stripe `customerId` field to the `User` model
- Updated relevant billing sections to support multiple subscriptions

## Testing Performed

- Tested running the Prisma migration on a demo database created on the
main branch

Will require a lot of additional testing.

## Checklist

- [ ] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [X] I have followed the project's coding style guidelines.

## Additional Notes

Added the following custom SQL statement to the migration:

> DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS
NULL;

Prior to deployment this will require changes to Stripe products:
- Adding `type` meta attribute

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2023-12-14 15:22:54 +11:00
Ephraim Atta-Duncan
31a9127c9e feat: templates 2023-12-14 12:24:56 +11:00
Mythie
6d34ebd91b fix: no longer available client component 2023-12-13 22:49:58 +11:00
Adithya Krishna
f2d4c0721d chore: removed packageManager as we have engines
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-11 23:38:07 +05:30
Adithya Krishna
f9139a54a5 chore: prevent frequent commenting for semantic pr titles
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-11 23:37:28 +05:30
Adithya Krishna
2d931b2c9b chore: fix caching issue in workflows
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-11 23:36:54 +05:30
Navindu Amarakoon
5c1d30bfbb chore: remove console log 2023-12-10 09:23:31 +05:30
Navindu Amarakoon
95041fa2e4 fix: build error 2023-12-09 12:05:36 +05:30
Navindu Amarakoon
49736d2587 Merge branch 'documenso:main' into feat/732-toggle-signup-form 2023-12-09 11:55:55 +05:30
Navindu Amarakoon
ee5ce78c82 chore: remove unused code 2023-12-09 11:48:46 +05:30
Navindu Amarakoon
3b3987dcf8 chore: add env to env.example 2023-12-09 11:43:30 +05:30
Navindu Amarakoon
78a1ee2af0 feat: disable oauth signup when DISABLE_SIGNUP is true 2023-12-09 11:35:45 +05:30
Navindu Amarakoon
dbdef79263 chore: remove old env variable from docker compose 2023-12-09 10:38:48 +05:30
Navindu Amarakoon
323380d757 feat: env variable to disable signing up 2023-12-09 10:37:16 +05:30
Lucas Smith
e4b7747f66 chore: run eslint fix on lint staged (#743) 2023-12-09 12:37:13 +11:00
Lucas Smith
0697e7f817 Merge branch 'main' into eslint-lint-staged 2023-12-09 11:41:20 +11:00
Lucas Smith
e1d3874e79 fix: do not lint js files 2023-12-09 11:40:23 +11:00
Aditya Deshlahre
497d9140d2 chore: add lint-staged task for dependency changes (#548) 2023-12-09 11:31:01 +11:00
nafees nazik
7d22957404 chore: add eslint fix command 2023-12-08 21:27:38 +05:30
Lucas Smith
38e5b1d3ce chore: use minio as s3 storage for document during development (#588) 2023-12-08 21:08:30 +11:00
Mythie
09dcc2cac0 chore: update github actions 2023-12-08 20:44:28 +11:00
Mythie
d8d36ae8e2 fix: sticky header z-positioning 2023-12-08 20:41:52 +11:00
Mythie
dfec8df31e fix: ensure command menu results are distinct 2023-12-08 20:41:52 +11:00
Lucas Smith
bc38009392 Merge branch 'main' into refactor-forms 2023-12-08 16:31:13 +11:00
Lucas Smith
5e9bc56329 feat: github repo management improvement (#728) 2023-12-08 15:47:55 +11:00
Mythie
48f6765e76 chore: add yml to lint-staged 2023-12-08 13:01:59 +11:00
Mythie
7feba02e08 chore: update ci and formatting 2023-12-08 13:01:36 +11:00
Adithya Krishna
520522bef7 chore: removed repo condition for codeql
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-05 12:05:47 +05:30
Adithya Krishna
52e696c90e chore: update pr labeler workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 18:12:43 +05:30
Adithya Krishna
02d91d9cd4 chore: updated workflows
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 18:04:05 +05:30
Adithya Krishna
ac529a89fc feat: check assignee and pr review reminder
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 18:00:28 +05:30
Adithya Krishna
f181099e74 chore: updated workflow permissions and run conditions
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 16:20:02 +05:30
Adithya Krishna
02e96bbd0a feat: added pr count workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 16:08:04 +05:30
Adithya Krishna
36e48e67ee chore: updated issue count workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 16:07:51 +05:30
Adithya Krishna
d8588b780a feat: added issue count workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 15:36:38 +05:30
Adithya Krishna
ef84f5ba98 chore: added EOLs
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 12:28:05 +05:30
Adithya Krishna
68120794f8 feat: added stale workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:29:30 +05:30
Adithya Krishna
88dc797423 chore: update semantic pr workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:29:15 +05:30
Adithya Krishna
a43be0432b feat: add triage issue workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:28:27 +05:30
Adithya Krishna
0f11cc0b4b feat: add first interaction workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:27:49 +05:30
Adithya Krishna
f310139a13 feat: add pr labeler workflow
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-04 00:27:17 +05:30
nafees nazik
369d08ae6e feat: refactor signin page 2023-12-02 17:54:55 +05:30
nafees nazik
a906833657 feat: use password input component 2023-12-02 17:54:19 +05:30
nafees nazik
4733f1e84b refactor: password input component 2023-12-02 17:46:16 +05:30
nafees nazik
ab0d38eaf4 Merge branch 'main' into refactor-forms 2023-12-02 17:24:06 +05:30
nafees nazik
6bbeaa084c refactor: forms 2023-11-30 15:55:29 +05:30
nafees nazik
231a307b89 feat: add loading text prop 2023-11-30 15:20:06 +05:30
nafees nazik
0b2dce2238 fix: type 2023-11-29 22:37:33 +05:30
nafees nazik
1e29dfd823 refactor: reset password form 2023-11-29 22:33:04 +05:30
nafees nazik
dc56c2abf2 refactor: password form 2023-11-29 22:32:42 +05:30
nafees nazik
62809e9506 refactor: signin page 2023-11-29 22:31:42 +05:30
nafees nazik
318dfcafc3 refactor: signup page 2023-11-29 22:31:24 +05:30
nafees nazik
4ff8592e8f feat: add password input component 2023-11-29 22:11:55 +05:30
249 changed files with 13607 additions and 1556 deletions

View File

@@ -29,15 +29,15 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT=
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION=
NEXT_PRIVATE_UPLOAD_REGION="unknown"
# REQUIRED: Defines the bucket to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_BUCKET=
NEXT_PRIVATE_UPLOAD_BUCKET="documenso"
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
# [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
@@ -78,15 +78,15 @@ NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.
NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Defines the host to use for PostHog.
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
# OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# This is only required for the marketing site
# [[REDIS]]

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,19 +1,20 @@
name: Playwright Tests
on:
push:
branches: [ "main" ]
branches: ['main']
pull_request:
branches: [ "main" ]
branches: ['main']
jobs:
e2e_tests:
name: "E2E Tests"
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci

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

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

View File

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

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

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

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

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

View File

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

View File

@@ -1,4 +1,4 @@
name: "Validate PR Name"
name: 'Validate PR Name'
on:
pull_request_target:
@@ -9,13 +9,54 @@ on:
- synchronize
permissions:
pull-requests: read
pull-requests: write
jobs:
validate-pr:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- name: Check PR creator's previous activity
id: check_activity
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
if [ "$ACTIVITY" -eq 0 ]; then
echo "::set-output name=is_new::true"
else
echo "::set-output name=is_new::false"
fi
- name: Count PRs created by user
id: count_prs
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
echo "::set-output name=pr_count::$PR_COUNT"
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey There! and thank you for opening this pull request! 📝👋🏼
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 💚🚀

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

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

View File

@@ -1,7 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",

View File

@@ -139,11 +139,13 @@ npm run d
1. **App** - http://localhost:3000
2. **Incoming Mail Access** - http://localhost:9000
3. **Database Connection Details**
- **Port**: 54320
- **Connection**: Use your favorite database client to connect using the provided port.
4. **S3 Storage Dashboard** - http://localhost:9001
## Developer Setup
### Manual Setup

View File

@@ -6,8 +6,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@@ -3,7 +3,7 @@
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyNewUsersChartProps = {
@@ -22,7 +22,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
return (
<div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">Monthly New Users</h3>
<h3 className="text-lg font-semibold">New Users</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">

View File

@@ -3,7 +3,7 @@
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyTotalUsersChartProps = {
@@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
return (
<div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">Monthly Total Users</h3>
<h3 className="text-lg font-semibold">Total Users</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">

View File

@@ -29,10 +29,7 @@ export function OpenPageTooltip() {
</svg>
</TooltipTrigger>
<TooltipContent>
<p>
August and earlier: Active subscribers. September and beyond: Numbers of active
subscriptions.
</p>
<p>Active Subscriptions.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -86,6 +86,7 @@ export const SinglePlayerClient = () => {
data.fields.map((field, i) => ({
id: i,
documentId: -1,
templateId: null,
recipientId: -1,
type: field.type,
page: field.pageNumber,
@@ -148,6 +149,7 @@ export const SinglePlayerClient = () => {
const placeholderRecipient: Recipient = {
id: -1,
documentId: -1,
templateId: null,
email: '',
name: '',
token: '',

View File

@@ -1,160 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
export const ZClaimPlanDialogFormSchema = z.object({
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
email: z.string().email(),
});
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
export type ClaimPlanDialogProps = {
className?: string;
planId: string;
children: React.ReactNode;
};
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
const params = useSearchParams();
const analytics = useAnalytics();
const event = usePlausible();
const { toast } = useToast();
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<TClaimPlanDialogFormSchema>({
defaultValues: {
name: params?.get('name') ?? '',
email: params?.get('email') ?? '',
},
resolver: zodResolver(ZClaimPlanDialogFormSchema),
});
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
try {
const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
const [redirectUrl] = await Promise.all([
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
delay,
]);
event('claim-plan-pricing');
analytics.capture('Marketing: Claim plan', { planId, email });
window.location.href = redirectUrl;
} catch (error) {
event('claim-plan-failed');
analytics.capture('Marketing: Claim plan failure', { planId, email });
toast({
title: 'Something went wrong',
description: error instanceof Error ? error.message : 'Please try again later.',
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isSubmitting && !open) {
reset();
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Claim your plan</DialogTitle>
<DialogDescription className="mt-4">
We're almost there! Please enter your email address and name to claim your plan.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
</div>
</div>
</div>
)}
<div>
<Label className="text-muted-foreground">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
<div>
<Label className="text-muted-foreground">Email</Label>
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<Button type="submit" size="lg" loading={isSubmitting}>
Claim the early adopters Plan (
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</fieldset>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,9 +1,9 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
@@ -16,14 +16,9 @@ export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
const params = useSearchParams();
const event = usePlausible();
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
? 'YEARLY'
: 'MONTHLY',
);
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY');
return (
<div className={cn('', className)} {...props}>

View File

@@ -6,8 +6,9 @@ import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { DocumentStatus, Signature } from '@documenso/prisma/client';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import type { Signature } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';

View File

@@ -26,6 +26,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { STEP } from '../constants';
import { FormErrorMessage } from '../form/form-error-message';
const ZWidgetFormSchema = z
@@ -48,13 +49,16 @@ const ZWidgetFormSchema = z
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
type StepKeys = keyof typeof STEP;
type StepValues = (typeof STEP)[StepKeys];
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
export const Widget = ({ className, children, ...props }: WidgetProps) => {
const { toast } = useToast();
const event = usePlausible();
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
const [step, setStep] = useState<StepValues>(STEP.EMAIL);
const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
@@ -81,11 +85,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => {
if (step === 'NAME') {
if (step === STEP.NAME) {
return 2;
}
if (step === 'SIGN') {
if (step === STEP.EMAIL) {
return 1;
}
@@ -93,16 +97,16 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
}, [step]);
const onNextStepClick = () => {
if (step === 'EMAIL') {
setStep('NAME');
if (step === STEP.EMAIL) {
setStep(STEP.NAME);
setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus();
}, 0);
}
if (step === 'NAME') {
setStep('SIGN');
if (step === STEP.NAME) {
setStep(STEP.SIGN);
setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus();
@@ -226,7 +230,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message}
onClick={() => step === 'EMAIL' && onNextStepClick()}
onClick={() => step === STEP.EMAIL && onNextStepClick()}
>
Next
</Button>
@@ -238,7 +242,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<FormErrorMessage error={errors.email} className="mt-1" />
</motion.div>
{(step === 'NAME' || step === 'SIGN') && (
{(step === STEP.NAME || step === STEP.SIGN) && (
<motion.div
key="name"
className="mt-4"

View File

@@ -0,0 +1,5 @@
export const STEP = {
EMAIL: 'EMAIL',
NAME: 'NAME',
SIGN: 'SIGN',
} as const;

View File

@@ -6,8 +6,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -7,9 +7,9 @@ import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { Document, User } from '@documenso/prisma/client';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Document, User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? recipientInitials(row.original.User.name)
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (

View File

@@ -9,7 +9,6 @@ import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
@@ -21,6 +20,8 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RoleCombobox } from './role-combobox';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
@@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl>
<Combobox
<RoleCombobox
listValues={roles}
onChange={(values: string[]) => onChange(values)}
/>

View File

@@ -0,0 +1,80 @@
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
type RoleComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
export const RoleCombobox = ({ listValues, onChange }: RoleComboboxProps) => {
const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
const dbRoles = Object.values(Role);
React.useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allRoles = [...new Set([...dbRoles, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={selectedValues.join(', ')} />
<CommandEmpty>No value found.</CommandEmpty>
<CommandGroup>
{allRoles.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{value}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@@ -8,7 +8,7 @@ import { Edit, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { Document, Role, Subscription } from '@documenso/prisma/client';
import type { Document, Role, Subscription } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -19,7 +19,7 @@ type UserData = {
name: string | null;
email: string;
roles: Role[];
Subscription?: SubscriptionLite | null;
Subscription?: SubscriptionLite[] | null;
Document: DocumentLite[];
};
@@ -35,9 +35,16 @@ type UsersDataTableProps = {
totalPages: number;
perPage: number;
page: number;
individualPriceIds: string[];
};
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
export const UsersDataTable = ({
users,
totalPages,
perPage,
page,
individualPriceIds,
}: UsersDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState('');
@@ -100,7 +107,13 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa
{
header: 'Subscription',
accessorKey: 'subscription',
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
},
{
header: 'Documents',

View File

@@ -1,3 +1,5 @@
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions';
@@ -14,12 +16,23 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || '';
const { users, totalPages } = await search(searchString, page, perPage);
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
getPricesByType('individual'),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
return (
<div>
<h2 className="text-4xl font-semibold">Manage users</h2>
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
<UsersDataTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import type { Team } from '@documenso/prisma/client';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
export type DocumentPageComponentProps = {
params: {
id: string;
};
team?: Team;
};
export default async function DocumentPageComponent({ params, team }: DocumentPageComponentProps) {
const { id } = params;
const documentId = Number(id);
const documentRootPath = team ? `/t/${team.url}/documents` : '/documents';
if (!documentId || Number.isNaN(documentId)) {
redirect(documentRootPath);
}
const { user } = await getRequiredServerComponentSession();
const document = await getDocumentById({
id: documentId,
userId: user.id,
}).catch(() => null);
if (!document || !document.documentData) {
redirect(documentRootPath);
}
const { documentData } = document;
const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({
documentId,
userId: user.id,
}),
await getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
{document.status !== InternalDocumentStatus.COMPLETED && (
<EditDocumentForm
className="mt-8"
document={document}
user={user}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</div>
)}
</div>
);
}

View File

@@ -31,6 +31,7 @@ export type EditDocumentFormProps = {
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
documentRootPath: string;
};
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
@@ -43,6 +44,7 @@ export const EditDocumentForm = ({
fields,
user: _user,
documentData,
documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -162,7 +164,7 @@ export const EditDocumentForm = ({
duration: 5000,
});
router.push('/documents');
router.push(documentRootPath);
} catch (err) {
console.error(err);

View File

@@ -1,18 +1,4 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import DocumentPageComponent from './document-page-component';
export type DocumentPageProps = {
params: {
@@ -20,80 +6,6 @@ export type DocumentPageProps = {
};
};
export default async function DocumentPage({ params }: DocumentPageProps) {
const { id } = params;
const documentId = Number(id);
if (!documentId || Number.isNaN(documentId)) {
redirect('/documents');
}
const { user } = await getRequiredServerComponentSession();
const document = await getDocumentById({
id: documentId,
userId: user.id,
}).catch(() => null);
if (!document || !document.documentData) {
redirect('/documents');
}
const { documentData } = document;
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
{document.status !== InternalDocumentStatus.COMPLETED && (
<EditDocumentForm
className="mt-8"
document={document}
user={user}
recipients={recipients}
fields={fields}
documentData={documentData}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</div>
)}
</div>
);
export default function DocumentPage({ params }: DocumentPageProps) {
return <DocumentPageComponent params={params} />;
}

View File

@@ -7,6 +7,7 @@ import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
@@ -19,9 +20,10 @@ export type DataTableActionButtonProps = {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
teamUrl?: string;
};
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
export const DataTableActionButton = ({ row, teamUrl }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@@ -38,6 +40,8 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const documentsPath = formatDocumentsPath(teamUrl);
const onDownloadClick = async () => {
try {
let document: DocumentWithData | null = null;
@@ -92,7 +96,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
})
.with({ isOwner: true, isDraft: true }, () => (
<Button className="w-32" asChild>
<Link href={`/documents/${row.id}`}>
<Link href={`${documentsPath}/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>

View File

@@ -18,6 +18,7 @@ import {
import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
@@ -40,9 +41,10 @@ export type DataTableActionDropdownProps = {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
teamUrl?: string;
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
export const DataTableActionDropdown = ({ row, teamUrl }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -62,6 +64,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
const documentsPath = formatDocumentsPath(teamUrl);
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
@@ -99,6 +103,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
};
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
@@ -116,7 +121,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
</DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
<Link href={`/documents/${row.id}`}>
<Link href={`${documentsPath}/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
@@ -164,6 +169,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DeleteDocumentDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
@@ -173,6 +179,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
teamUrl={teamUrl}
/>
)}
</DropdownMenu>

View File

@@ -0,0 +1,63 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { parseToNumberArray } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { Combobox } from '@documenso/ui/primitives/combobox';
type DataTableSenderFilterProps = {
teamId: number;
};
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const isMounted = useIsMounted();
const senderIds = parseToNumberArray(searchParams?.get('senderIds') ?? '');
const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
teamId,
});
const comboBoxOptions = (data ?? []).map((member) => ({
label: member.user.name ?? member.user.email,
value: member.user.id,
}));
const onChange = (newSenderIds: number[]) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('senderIds', newSenderIds.join(','));
if (newSenderIds.length === 0) {
params.delete('senderIds');
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
return (
<Combobox
emptySelectionPlaceholder={
<p className="text-muted-foreground font-normal">
<span className="text-muted-foreground/70">Sender:</span> All
</p>
}
enableClearAllButton={true}
inputPlaceholder="Search"
loading={!isMounted || isInitialLoading}
options={comboBoxOptions}
selectedValues={senderIds}
onChange={onChange}
/>
);
};

View File

@@ -27,9 +27,15 @@ export type DocumentsDataTableProps = {
User: Pick<User, 'id' | 'name' | 'email'>;
}
>;
showSenderColumn?: boolean;
teamUrl?: string;
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
export const DocumentsDataTable = ({
results,
showSenderColumn,
teamUrl,
}: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
@@ -61,6 +67,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
id: 'sender',
header: 'Sender',
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
},
{
header: 'Recipient',
accessorKey: 'recipient',
@@ -79,8 +90,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} />
<DataTableActionButton teamUrl={teamUrl} row={row.original} />
<DataTableActionDropdown teamUrl={teamUrl} row={row.original} />
</div>
),
},
@@ -90,6 +101,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: Boolean(showSenderColumn),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
@@ -21,6 +21,7 @@ type DeleteDraftDocumentDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
documentTitle: string;
};
export const DeleteDocumentDialog = ({
@@ -28,6 +29,7 @@ export const DeleteDocumentDialog = ({
open,
onOpenChange,
status,
documentTitle,
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
@@ -42,7 +44,7 @@ export const DeleteDocumentDialog = ({
toast({
title: 'Document deleted',
description: 'Your document has been successfully deleted.',
description: `"${documentTitle}" has been successfully deleted`,
duration: 5000,
});
@@ -50,6 +52,13 @@ export const DeleteDocumentDialog = ({
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onDelete = async () => {
try {
await deleteDocument({ id, status });
@@ -72,7 +81,7 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this document?</DialogTitle>
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be
@@ -81,7 +90,7 @@ export const DeleteDocumentDialog = ({
</DialogHeader>
{status !== DocumentStatus.DRAFT && (
<div className="mt-8">
<div className="mt-4">
<Input
type="text"
value={inputValue}

View File

@@ -0,0 +1,154 @@
import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { parseToNumberArray } from '@documenso/lib/utils/params';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import {
type PeriodSelectorValue,
isPeriodSelectorValue,
} from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
import { DataTableSenderFilter } from './data-table-sender-filter';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
export type DocumentsPageComponentProps = {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
senderIds?: string;
};
team?: Team & { teamEmail?: TeamEmail | null };
};
export default async function DocumentsPageComponent({
searchParams = {},
team,
}: DocumentsPageComponentProps) {
const { user } = await getRequiredServerComponentSession();
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const documentsPath = team ? `/t/${team.url}/documents` : '/documents';
const senderIds = parseToNumberArray(searchParams.senderIds ?? '');
let teamStatOptions: GetStatsInput['team'] = undefined;
if (team) {
teamStatOptions = {
teamId: team.id,
teamEmail: team.teamEmail?.email,
senderIds,
};
}
const stats = await getStats({
user,
team: teamStatOptions,
});
const results = await findDocuments({
userId: user.id,
teamId: team?.id,
status,
orderBy: {
column: 'createdAt',
direction: 'desc',
},
page,
perPage,
period,
senderIds,
});
const getTabHref = (value: typeof status) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (params.has('page')) {
params.delete('page');
}
return `${documentsPath}?${params.toString()}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<UploadDocument team={team ? { id: team.id, url: team.url } : undefined} />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="text-4xl font-semibold">Documents</h1>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs defaultValue={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DataTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{results.count > 0 && (
<DocumentsDataTable
results={results}
showSenderColumn={team !== undefined}
teamUrl={team?.url}
/>
)}
{results.count === 0 && <EmptyDocumentState status={status} />}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useRouter } from 'next/navigation';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -16,12 +17,14 @@ type DuplicateDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
teamUrl?: string;
};
export const DuplicateDocumentDialog = ({
id,
open,
onOpenChange,
teamUrl,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
@@ -37,10 +40,13 @@ export const DuplicateDocumentDialog = ({
}
: undefined;
const documentsPath = formatDocumentsPath(teamUrl);
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`/documents/${newId}`);
router.push(`${documentsPath}/${newId}`);
toast({
title: 'Document Duplicated',
description: 'Your document has been successfully duplicated.',

View File

@@ -1,114 +1,10 @@
import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
import type { DocumentsPageComponentProps } from './documents-page-component';
import DocumentsPageComponent from './documents-page-component';
export type DocumentsPageProps = {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
};
searchParams?: DocumentsPageComponentProps['searchParams'];
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const { user } = await getRequiredServerComponentSession();
const stats = await getStats({
user,
});
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const results = await findDocuments({
userId: user.id,
status,
orderBy: {
column: 'createdAt',
direction: 'desc',
},
page,
perPage,
period,
});
const getTabHref = (value: typeof status) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (params.has('page')) {
params.delete('page');
}
return `/documents?${params.toString()}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<UploadDocument />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<h1 className="text-4xl font-semibold">Documents</h1>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs defaultValue={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{results.count > 0 && <DocumentsDataTable results={results} />}
{results.count === 0 && <EmptyDocumentState status={status} />}
</div>
</div>
);
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
return <DocumentsPageComponent searchParams={searchParams} />;
}

View File

@@ -20,9 +20,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type UploadDocumentProps = {
className?: string;
team?: {
id: number;
url: string;
};
};
export const UploadDocument = ({ className }: UploadDocumentProps) => {
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
@@ -49,6 +53,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
teamId: team?.id,
});
toast({
@@ -63,7 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
router.push(`/documents/${id}`);
router.push(team?.id !== undefined ? `/t/${team.url}/documents/${id}` : `/documents/${id}`);
} catch (error) {
console.error(error);
@@ -94,11 +99,13 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
/>
<div className="absolute -bottom-6 right-0">
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
{team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
</div>
{isLoading && (
@@ -107,7 +114,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
</div>
)}
{remaining.documents === 0 && (
{team?.id === undefined && remaining.documents === 0 && (
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
<div className="text-center">
<h2 className="text-muted-foreground/80 text-xl font-semibold">

View File

@@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
@@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
redirect('/signin');
}
const { user } = await getRequiredServerComponentSession();
const [{ user }, teams] = await Promise.all([
getRequiredServerComponentSession(),
getTeams({ userId: session.user.id }),
]);
return (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Header user={user} />
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { Button } from '@documenso/ui/primitives/button';

View File

@@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
export const BillingPortalButton = () => {
export type BillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
};
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
};
return (
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
<Button
{...buttonProps}
onClick={async () => handleFetchPortalUrl()}
loading={isFetchingPortalUrl}
>
Manage Subscription
</Button>
);

View File

@@ -1,46 +1,13 @@
'use server';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export const createBillingPortal = async () => {
const { user } = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
const { stripeCustomer } = await getStripeCustomerByUser(user);
return getPortalSession({
customerId: stripeCustomer.id,

View File

@@ -1,55 +1,36 @@
'use server';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
export type CreateCheckoutOptions = {
priceId: string;
};
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
const { user } = await getRequiredServerComponentSession();
const session = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
const { user, stripeCustomer } = await getStripeCustomerByUser(session.user);
let stripeCustomer: Stripe.Customer | null = null;
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
// Find the Stripe customer for the current user subscription.
if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
const foundSubscription = existingSubscriptions.find(
(subscription) =>
subscription.priceId === priceId &&
subscription.periodEnd &&
subscription.periodEnd >= new Date(),
);
if (foundSubscription) {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
await createCustomer({
user,
});
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,

View File

@@ -2,12 +2,15 @@ import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { LocaleDate } from '~/components/formatter/locale-date';
@@ -15,7 +18,7 @@ import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button';
export default async function BillingSettingsPage() {
const { user } = await getRequiredServerComponentSession();
let { user } = await getRequiredServerComponentSession();
const isBillingEnabled = await getServerComponentFlag('app_billing');
@@ -24,20 +27,36 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile');
}
const [subscription, prices] = await Promise.all([
getSubscriptionByUserId({ userId: user.id }),
getPricesByInterval(),
if (!user.customerId) {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, individualPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ type: 'individual' }),
getPricesByType('individual'),
]);
const individualPriceIds = individualPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
individualPriceIds.includes(priceId),
);
const subscription =
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
individualUserSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
() => null,
);
}
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
const isMissingOrInactiveOrFreePlan =
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
return (
<div>

View File

@@ -0,0 +1,45 @@
'use client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AcceptTeamInvitationButtonProps = {
teamId: number;
};
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
const { toast } = useToast();
const {
mutateAsync: acceptTeamInvitation,
isLoading,
isSuccess,
} = trpc.team.acceptTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Accepted team invitation',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to join this team at this time.',
});
},
});
return (
<Button
onClick={async () => acceptTeamInvitation({ teamId })}
loading={isLoading}
disabled={isLoading || isSuccess}
>
Accept
</Button>
);
};

View File

@@ -0,0 +1,46 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { trpc } from '@documenso/trpc/react';
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
import CreateTeamDialog from '~/components/(teams)/dialogs/create-team-dialog';
import UserTeamsPageDataTable from '~/components/(teams)/tables/user-teams-page-data-table';
import TeamEmailUsage from './team-email-usage';
import { TeamInvitations } from './team-invitations';
export default function TeamsSettingsPage() {
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
return (
<div>
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
<CreateTeamDialog />
</SettingsHeader>
<UserTeamsPageDataTable />
<AnimatePresence>
{teamEmail && (
<motion.section
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<TeamEmailUsage teamEmail={teamEmail} />
</motion.section>
)}
</AnimatePresence>
<TeamInvitations />
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import type { TeamEmail } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamEmailUsageProps = {
teamEmail: TeamEmail & { team: { name: string; url: string } };
};
export default function TeamEmailUsage({ teamEmail }: TeamEmailUsageProps) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'You have successfully revoked access.',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description:
'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
});
},
});
return (
<div className="mt-8 flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
<div className="text-sm">
<h3 className="text-base font-medium">Team email</h3>
<p className="text-muted-foreground">
Your email is currently being used by team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
).
</p>
<p className="text-muted-foreground mt-1">They have permission on your behalf to:</p>
<ul className="text-muted-foreground mt-0.5 list-inside list-disc">
<li>Display your name and email in documents</li>
<li>View all documents sent to your account</li>
</ul>
</div>
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">Revoke access</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription className="mt-4">
You are about to revoke access for team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
use your email.
</DialogDescription>
</DialogHeader>
<fieldset disabled={isDeletingTeamEmail}>
<DialogFooter className="space-x-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingTeamEmail}
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
>
Revoke
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { BellIcon } from 'lucide-react';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
export const TeamInvitations = () => {
const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
return (
<AnimatePresence>
{data && data.length > 0 && !isInitialLoading && (
<motion.div
className="mt-8 flex flex-row items-center justify-between rounded-md bg-blue-50 p-6"
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
{/* Todo: Teams - Extract into `Alerts` component? */}
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
<div className="text-sm text-blue-700">
You have <strong>{data.length}</strong> pending team invitation
{data.length > 1 ? 's' : ''}.
</div>
<Dialog>
<DialogTrigger asChild>
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
View invites
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Pending invitations</DialogTitle>
<DialogDescription className="mt-4">
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
</DialogDescription>
</DialogHeader>
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
{data.map((invitation) => (
<li key={invitation.teamId}>
<AvatarWithText
className="w-full max-w-none py-4"
avatarFallback={invitation.team.name.slice(0, 1)}
primaryText={
<span className="text-foreground/80 font-semibold">
{invitation.team.name}
</span>
}
secondaryText={formatTeamUrl(invitation.team.url)}
rightSideComponent={
<div className="ml-auto">
<AcceptTeamInvitationButton teamId={invitation.team.id} />
</div>
}
/>
</li>
))}
</ul>
</DialogContent>
</Dialog>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EditTemplateFormProps = {
className?: string;
user: User;
template: Template;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
};
type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
export const EditTemplateForm = ({
className,
template,
recipients,
fields,
user: _user,
documentData,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('signers');
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await addTemplateSigners({
templateId: template.id,
signers: data.signers,
});
router.refresh();
setStep('fields');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
toast({
title: 'Template saved',
description: 'Your templates has been saved successfully.',
duration: 5000,
});
router.push('/templates');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

@@ -0,0 +1,81 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageProps = {
params: {
id: string;
};
};
export default async function TemplatePage({ params }: TemplatePageProps) {
const { id } = params;
const templateId = Number(id);
if (!templateId || Number.isNaN(templateId)) {
redirect('/documents');
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect('/documents');
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
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>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteTemplateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template deleted',
description: 'Your template has been successfully deleted.',
duration: 5000,
});
onOpenChange(false);
},
});
const onDeleteTemplate = async () => {
try {
await deleteTemplate({ id });
} catch {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this template?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,228 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { FilePlus, X } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
export const NewTemplateDialog = () => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
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>
);
};

View File

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

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { GetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';
@@ -12,10 +13,16 @@ export type SigningLayoutProps = {
export default async function SigningLayout({ children }: SigningLayoutProps) {
const { user, session } = await getServerComponentSession();
let teams: GetTeamsResponse = [];
if (user && session) {
teams = await getTeams({ userId: user.id });
}
return (
<NextAuthProvider session={session}>
<div className="min-h-screen">
{user && <AuthenticatedHeader user={user} />}
{user && <AuthenticatedHeader user={user} teams={teams} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
</div>

View File

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

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header } from '~/components/(dashboard)/layout/header';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
export type AuthenticatedDashboardLayoutProps = {
children: React.ReactNode;
};
export default async function AuthenticatedTeamsDashboardLayout({
children,
}: AuthenticatedDashboardLayoutProps) {
const session = await getServerSession(NEXT_AUTH_OPTIONS);
if (!session) {
redirect('/signin');
}
const [{ user }, teams] = await Promise.all([
getRequiredServerComponentSession(),
getTeams({ userId: session.user.id }),
]);
return (
<NextAuthProvider session={session}>
<LimitsProvider>
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus />
</LimitsProvider>
</NextAuthProvider>
);
}

View File

@@ -0,0 +1,20 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-component';
export type DocumentPageProps = {
params: {
id: string;
teamUrl: string;
};
};
export default async function DocumentPage({ params }: DocumentPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentPageComponent params={params} team={team} />;
}

View File

@@ -0,0 +1,18 @@
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
export default function DocumentSentPage() {
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
</h1>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import type { DocumentsPageComponentProps } from '~/app/(dashboard)/documents/documents-page-component';
import DocumentsPageComponent from '~/app/(dashboard)/documents/documents-page-component';
export type TeamsDocumentPageProps = {
params: {
teamUrl: string;
};
searchParams?: DocumentsPageComponentProps['searchParams'];
};
export default async function TeamsDocumentPage({
params,
searchParams = {},
}: TeamsDocumentPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentsPageComponent searchParams={searchParams} team={team} />;
}

View File

@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
type ErrorProps = {
error: Error & { digest?: string };
};
export default function ErrorPage({ error }: ErrorProps) {
const router = useRouter();
let errorMessage = 'Unknown error';
let errorDetails = '';
if (error.message === AppErrorCode.UNAUTHORIZED) {
errorMessage = 'Unauthorized';
errorDetails = 'You are not authorized to view this page.';
}
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">{errorMessage}</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
<p className="text-muted-foreground mt-4 text-sm">{errorDetails}</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="ghost"
className="w-32"
onClick={() => {
void router.back();
}}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
<Button asChild>
<Link href="/settings/teams">View teams</Link>
</Button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,85 @@
import { DateTime } from 'luxon';
import type Stripe from 'stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { BillingPortalButton } from '~/app/(dashboard)/settings/billing/billing-portal-button';
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
import TeamBillingInvoicesDataTable from '~/components/(teams)/tables/team-billing-invoices-data-table';
export type TeamsSettingsBillingPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
const isUserOwnerOfTeam = team.ownerUserId === session.user.id;
let teamSubscription: Stripe.Subscription | null = null;
if (team.subscriptionId) {
teamSubscription = await stripe.subscriptions.retrieve(team.subscriptionId);
}
const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
if (!subscription) {
return 'No payment required';
}
const numberOfSeats = subscription.items.data[0].quantity ?? 0;
const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member';
const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
'LLL dd, yyyy',
);
return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`;
};
return (
<div>
<SettingsHeader title="Billing" subtitle="Your subscription is currently active." />
<Card gradient className="shadow-sm">
<CardContent className="flex flex-row items-center justify-between p-4">
<div className="flex flex-col text-sm">
<p className="text-foreground font-semibold">
Current plan: {teamSubscription ? 'Team' : 'Community Team'}
</p>
<p className="text-muted-foreground mt-0.5">
{formatTeamSubscriptionDetails(teamSubscription)}
</p>
</div>
{teamSubscription && (
<div
title={
isUserOwnerOfTeam
? 'Manage your team subscription.'
: 'You must be the owner of this team to directly manage the billing.'
}
>
<BillingPortalButton buttonProps={{ disabled: !isUserOwnerOfTeam }} />
</div>
)}
</CardContent>
</Card>
<section className="mt-6">
<TeamBillingInvoicesDataTable teamId={team.id} />
</section>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { notFound } from 'next/navigation';
import { canExecuteTeamAction } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
export type DashboardSettingsLayoutProps = {
children: React.ReactNode;
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsLayout({
children,
params: { teamUrl },
}: DashboardSettingsLayoutProps) {
const session = await getRequiredServerComponentSession();
try {
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw new Error(AppErrorCode.UNAUTHORIZED);
}
} catch (e) {
const error = AppError.parseError(e);
if (error.code === 'P2025') {
notFound();
}
throw e;
}
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Team Settings</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<DesktopNav className="hidden md:col-span-3 md:flex" />
<MobileNav className="col-span-12 mb-8 md:hidden" />
<div className="col-span-12 md:col-span-9">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
import InviteTeamMembersDialog from '~/components/(teams)/dialogs/invite-team-member-dialog';
import TeamsMemberPageDataTable from '~/components/(teams)/tables/teams-member-page-data-table';
export type TeamsSettingsMembersPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
return (
<div>
<SettingsHeader title="Members" subtitle="Manage the members or invite new members.">
<InviteTeamMembersDialog teamId={team.id} />
</SettingsHeader>
<TeamsMemberPageDataTable
teamId={team.id}
teamName={team.name}
teamOwnerUserId={team.ownerUserId}
/>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { CheckCircle2, Clock } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
import AddTeamEmailDialog from '~/components/(teams)/dialogs/add-team-email-dialog';
import DeleteTeamDialog from '~/components/(teams)/dialogs/delete-team-dialog';
import TransferTeamDialog from '~/components/(teams)/dialogs/transfer-team-dialog';
import UpdateTeamForm from '~/components/(teams)/forms/update-team-form';
import TeamEmailDropdown from './team-email-dropdown';
import { TeamTransferStatus } from './team-transfer-status';
export type TeamsSettingsPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
const isTransferVerificationExpired =
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
return (
<div>
<SettingsHeader title="Team Profile" subtitle="Here you can edit your team's details." />
<TeamTransferStatus
teamId={team.id}
transferVerification={team.transferVerification}
className="mb-4"
/>
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(team.teamEmail || team.emailVerification) && (
<section className="rounded-lg bg-gray-50/70 p-6 pb-2">
<h3 className="font-medium">Team email</h3>
<p className="text-muted-foreground text-sm">
You can view documents associated with this email and use this identity when sending
documents.
</p>
<hr className="border-border/50 mt-2" />
<div className="flex flex-row items-center justify-between py-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={extractInitials(
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
)}
primaryText={
<span className="text-foreground/80 text-sm font-semibold">
{team.teamEmail?.name || team.emailVerification?.name}
</span>
}
secondaryText={
<span className="text-sm">
{team.teamEmail?.email || team.emailVerification?.email}
</span>
}
/>
<div className="flex flex-row items-center pr-2">
<div className="text-muted-foreground mr-4 flex flex-row items-center text-sm xl:mr-8">
{team.teamEmail ? (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
Active
</>
) : team.emailVerification && team.emailVerification.expiresAt < new Date() ? (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
Expired
</>
) : (
team.emailVerification && (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
Awaiting email confirmation
</>
)
)}
</div>
<TeamEmailDropdown team={team} />
</div>
</div>
</section>
)}
{!team.teamEmail && !team.emailVerification && (
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
<div>
<h3 className="font-medium">Team email</h3>
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
<li>Display this name and email when sending documents</li>
<li>View documents associated with this email</li>
</ul>
</div>
<AddTeamEmailDialog teamId={team.id} />
</div>
)}
{team.ownerUserId === session.user.id && (
<>
{isTransferVerificationExpired && (
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
<div>
<h3 className="font-medium">Transfer team</h3>
<p className="text-muted-foreground text-sm">
Transfer the ownership of the team to another team member.
</p>
</div>
<TransferTeamDialog
ownerUserId={team.ownerUserId}
teamId={team.id}
teamName={team.name}
/>
</div>
)}
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
<div>
<h3 className="font-medium">Delete team</h3>
<p className="text-muted-foreground text-sm">
This team, and any associated data excluding billing invoices will be permanently
deleted.
</p>
</div>
<DeleteTeamDialog teamId={team.id} teamName={team.name} />
</div>
</>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import { useRouter } from 'next/navigation';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import UpdateTeamEmailDialog from '~/components/(teams)/dialogs/update-team-email-dialog';
export type TeamsSettingsPageProps = {
team: Awaited<ReturnType<typeof getTeamByUrl>>;
};
export default function TeamEmailDropdown({ team }: TeamsSettingsPageProps) {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } =
trpc.team.resendTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Email verification has been resent',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to resend verification at this time. Please try again.',
});
},
});
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Team email has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove team email at this time. Please try again.',
});
},
});
const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
trpc.team.deleteTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Email verification has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove email verification at this time. Please try again.',
});
},
});
const onRemove = async () => {
if (team.teamEmail) {
await deleteTeamEmail({ teamId: team.id });
}
if (team.emailVerification) {
await deleteTeamEmailVerification({ teamId: team.id });
}
router.refresh();
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!team.teamEmail && team.emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<Loader className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Resend verification
</DropdownMenuItem>
)}
{team.teamEmail && (
<UpdateTeamEmailDialog
teamEmail={team.teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
}
/>
)}
<DropdownMenuItem
disabled={isDeletingTeamEmail || isDeletingTeamEmailVerification}
onClick={async () => onRemove()}
>
<X className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import type { TeamTransferVerification } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamTransferStatusProps = {
className?: string;
teamId: number;
transferVerification: TeamTransferVerification | null;
};
export const TeamTransferStatus = ({
className,
teamId,
transferVerification,
}: TeamTransferStatusProps) => {
const router = useRouter();
const { toast } = useToast();
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
const { mutateAsync: deleteTeamTransferRequest, isLoading } =
trpc.team.deleteTeamTransferRequest.useMutation({
onSuccess: () => {
if (!isExpired) {
toast({
title: 'Success',
description: 'The team transfer invitation has been successfully deleted.',
duration: 5000,
});
}
router.refresh();
},
onError: () => {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.',
});
},
});
return (
<AnimatePresence>
{transferVerification && (
<motion.div
className={cn(
'flex flex-row items-center justify-between rounded-lg border-2 border-yellow-400 bg-yellow-200 px-6 py-4 dark:border-yellow-600 dark:bg-yellow-400',
className,
)}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<div className="text-yellow-900">
<h3 className="font-medium">
{isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
</h3>
{isExpired ? (
<p className="text-sm">
The team transfer request to <strong>{transferVerification.name}</strong> has
expired.
</p>
) : (
<section className="text-sm">
<p>
A request to transfer the ownership of this team has been sent to{' '}
<strong>{transferVerification.name}</strong>
</p>
<p>If they accept this request, the team will be transferred to their account.</p>
</section>
)}
</div>
<Button
onClick={async () => deleteTeamTransferRequest({ teamId })}
loading={isLoading}
variant="destructive"
className="ml-auto mt-2"
>
{isExpired ? 'Close' : 'Cancel'}
</Button>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@@ -2,7 +2,15 @@ import Link from 'next/link';
import { SignInForm } from '~/components/forms/signin';
export default function SignInPage() {
type SignInPageProps = {
searchParams: {
email?: string;
};
};
export default function SignInPage({ searchParams }: SignInPageProps) {
const email = typeof searchParams.email === 'string' ? searchParams.email : undefined;
return (
<div>
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
@@ -11,14 +19,16 @@ export default function SignInPage() {
Welcome back, we are lucky to have you.
</p>
<SignInForm className="mt-4" />
<SignInForm initialEmail={email} className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
)}
<p className="mt-2.5 text-center">
<Link

View File

@@ -1,8 +1,21 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpForm } from '~/components/forms/signup';
export default function SignUpPage() {
type SignUpPageProps = {
searchParams: {
email?: string;
};
};
export default function SignUpPage({ searchParams }: SignUpPageProps) {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin');
}
const email = typeof searchParams.email === 'string' ? searchParams.email : undefined;
return (
<div>
<h1 className="text-4xl font-semibold">Create a new account</h1>
@@ -12,7 +25,7 @@ export default function SignUpPage() {
signing is within your grasp.
</p>
<SignUpForm className="mt-4" />
<SignUpForm initialEmail={email} className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}

View File

@@ -0,0 +1,119 @@
import Link from 'next/link';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
import { getTeamById } from '@documenso/lib/server-only/team/get-teams';
import { prisma } from '@documenso/prisma';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
type AcceptInvitationPageProps = {
params: {
token: string;
};
};
export default async function AcceptInvitationPage({
params: { token },
}: AcceptInvitationPageProps) {
const session = await getServerComponentSession();
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
where: {
token,
},
});
if (!teamMemberInvite) {
return (
<div>
<h1 className="text-4xl font-semibold">Invalid token</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This token is invalid or has expired. Please contact your team for a new invitation.
</p>
<Button asChild>
<Link href="/">Return</Link>
</Button>
</div>
);
}
const team = await getTeamById({ teamId: teamMemberInvite.teamId });
const user = await prisma.user.findFirst({
where: {
email: {
equals: teamMemberInvite.email,
mode: 'insensitive',
},
},
});
// Directly convert the team member invite to a team member if they already have an account.
if (user) {
await acceptTeamInvitation({ userId: user.id, teamId: team.id });
}
// Set the team invite status to accepted, which is checked during user creation
// to determine if we should add the user to the team at that time.
if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) {
await prisma.teamMemberInvite.update({
where: {
id: teamMemberInvite.id,
},
data: {
status: TeamMemberInviteStatus.ACCEPTED,
},
});
}
const isSessionUserTheInvitedUser = user && user.id === session.user?.id;
if (!user) {
return (
<div>
<h1 className="text-4xl font-semibold">Team invitation</h1>
<p className="text-muted-foreground mt-2 text-sm">
You have been invited by <strong>{team.name}</strong> to join their team.
</p>
<p className="text-muted-foreground mb-4 mt-1 text-sm">
To accept this invitation you must create an account.
</p>
<Button asChild>
<Link href={`/signup?email=${encodeURIComponent(teamMemberInvite.email)}`}>
Create account
</Link>
</Button>
</div>
);
}
return (
<div>
<h1 className="text-4xl font-semibold">Invitation accepted!</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
You have accepted an invitation from <strong>{team.name}</strong> to join their team.
</p>
{user && !isSessionUserTheInvitedUser && (
<Button asChild>
<Link href={`/signin?email=${encodeURIComponent(teamMemberInvite.email)}`}>
Continue to login
</Link>
</Button>
)}
{isSessionUserTheInvitedUser && (
<Button asChild>
<Link href="/">Continue</Link>
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import Link from 'next/link';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
type VerifyTeamEmailPageProps = {
params: {
token: string;
};
};
export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) {
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
where: {
token,
},
include: {
team: true,
},
});
if (!teamEmailVerification || teamEmailVerification.expiresAt < new Date()) {
return (
<div>
<h1 className="text-4xl font-semibold">Invalid link</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This link is invalid or has expired. Please contact your team to resend a verification.
</p>
<Button asChild>
<Link href="/">Return</Link>
</Button>
</div>
);
}
const { team } = teamEmailVerification;
try {
await prisma.$transaction([
prisma.teamEmailVerification.deleteMany({
where: {
teamId: team.id,
},
}),
prisma.teamEmail.create({
data: {
teamId: team.id,
email: teamEmailVerification.email,
name: teamEmailVerification.name,
},
}),
]);
} catch {
return (
<div>
<h1 className="text-4xl font-semibold">Team email verification</h1>
<p className="text-muted-foreground mt-2 text-sm">
Something went wrong while attempting to verify your email address for{' '}
<strong>{team.name}</strong>. Please try again later.
</p>
</div>
);
}
return (
<div>
<h1 className="text-4xl font-semibold">Team email verified!</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
You have verified your email address for <strong>{team.name}</strong>.
</p>
<Button asChild>
<Link href="/">Continue</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import Link from 'next/link';
import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
type VerifyTeamTransferPage = {
params: {
token: string;
};
};
export default async function VerifyTeamTransferPage({
params: { token },
}: VerifyTeamTransferPage) {
const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
where: {
token,
},
include: {
team: true,
},
});
if (!teamTransferVerification || teamTransferVerification.expiresAt < new Date()) {
return (
<div>
<h1 className="text-4xl font-semibold">Invalid link</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This link is invalid or has expired. Please contact your team to resend a transfer
request.
</p>
<Button asChild>
<Link href="/">Return</Link>
</Button>
</div>
);
}
const { team } = teamTransferVerification;
try {
await transferTeamOwnership({ token });
} catch (e) {
console.error(e);
return (
<div>
<h1 className="text-4xl font-semibold">Team ownership transfer</h1>
<p className="text-muted-foreground mt-2 text-sm">
Something went wrong while attempting to transfer the ownership of team{' '}
<strong>{team.name}</strong> to your. Please try again later or contact support.
</p>
</div>
);
}
return (
<div>
<h1 className="text-4xl font-semibold">Team ownership transferred!</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
The ownership of team <strong>{team.name}</strong> has been successfully transferred to you.
</p>
<Button asChild>
<Link href={`/t/${team.url}/settings`}>Continue</Link>
</Button>
</div>
);
}

View File

@@ -22,6 +22,7 @@ import {
CommandList,
CommandShortcut,
} from '@documenso/ui/primitives/command';
import { THEMES_TYPE } from '@documenso/ui/primitives/constants';
const DOCUMENTS_PAGES = [
{
@@ -79,14 +80,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
return searchDocumentsData.map((document) => ({
label: document.title,
path: `/documents/${document.id}`,
value:
document.title + ' ' + document.Recipient.map((recipient) => recipient.email).join(' '),
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
}));
}, [searchDocumentsData]);
const currentPage = pages[pages.length - 1];
const toggleOpen = () => {
const toggleOpen = (e: KeyboardEvent) => {
e.preventDefault();
setIsOpen((isOpen) => !isOpen);
onOpenChange?.(!isOpen);
@@ -215,9 +216,9 @@ const Commands = ({
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
const THEMES = useMemo(
() => [
{ label: 'Light Mode', theme: 'light', icon: Sun },
{ label: 'Dark Mode', theme: 'dark', icon: Moon },
{ label: 'System Theme', theme: 'system', icon: Monitor },
{ label: 'Light Mode', theme: THEMES_TYPE.LIGHT, icon: Sun },
{ label: 'Dark Mode', theme: THEMES_TYPE.DARK, icon: Moon },
{ label: 'System Theme', theme: THEMES_TYPE.SYSTEM, icon: Monitor },
],
[],
);

View File

@@ -3,6 +3,9 @@
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Search } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
@@ -10,10 +13,22 @@ import { Button } from '@documenso/ui/primitives/button';
import { CommandMenu } from '../common/command-menu';
const navigationLinks = [
{
href: '/documents',
label: 'Documents',
},
{
href: '/templates',
label: 'Templates',
},
];
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
// const pathname = usePathname();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
@@ -26,9 +41,29 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
return (
<div
className={cn('ml-8 hidden flex-1 gap-x-6 md:flex md:justify-center', className)}
className={cn(
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
className,
)}
{...props}
>
<div className="flex items-baseline gap-x-6">
{navigationLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className={cn(
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
},
)}
>
{label}
</Link>
))}
</div>
<CommandMenu open={open} onOpenChange={setOpen} />
<Button
@@ -47,19 +82,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</div>
</div>
</Button>
{/* We have no other subpaths rn */}
{/* <Link
href="/documents"
className={cn(
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground': pathname?.startsWith('/documents'),
},
)}
>
Documents
</Link> */}
</div>
);
};

View File

@@ -1,10 +1,12 @@
'use client';
import { HTMLAttributes, useEffect, useState } from 'react';
import { type HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { User } from '@documenso/prisma/client';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Logo } from '~/components/branding/logo';
@@ -14,9 +16,12 @@ import { ProfileDropdown } from './profile-dropdown';
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
user: User;
teams: GetTeamsResponse;
};
export const Header = ({ className, user, ...props }: HeaderProps) => {
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
const params = useParams();
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
@@ -29,10 +34,18 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
return () => window.removeEventListener('scroll', onScroll);
}, []);
const getRootHref = () => {
if (typeof params?.teamUrl === 'string') {
return `/t/${params.teamUrl}`;
}
return '/';
};
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[1000] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className,
)}
@@ -40,7 +53,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link
href="/"
href={getRootHref()}
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Logo className="h-6 w-auto" />
@@ -48,12 +61,8 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
<DesktopNav />
<div className="flex gap-x-4">
<ProfileDropdown user={user} />
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
<Menu className="h-6 w-6" />
</Button> */}
<div className="flex gap-x-4 md:ml-8">
<ProfileDropdown user={user} teams={teams} />
</div>
</div>
</header>

View File

@@ -1,154 +1,210 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
CreditCard,
Lock,
LogOut,
User as LucideUser,
Monitor,
Moon,
Palette,
Sun,
UserCog,
} from 'lucide-react';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { LuGithub } from 'react-icons/lu';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { TEAM_MEMBER_ROLE_MAP, canExecuteTeamAction } from '@documenso/lib/constants/teams';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
export type ProfileDropdownProps = {
user: User;
teams: GetTeamsResponse;
};
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { getFlag } = useFeatureFlags();
const { theme, setTheme } = useTheme();
export const ProfileDropdown = ({ user, teams: initialTeamsData }: ProfileDropdownProps) => {
const pathname = usePathname();
const isUserAdmin = isAdmin(user);
const isBillingEnabled = getFlag('app_billing');
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
initialData: initialTeamsData,
});
const avatarFallback = user.name
? recipientInitials(user.name)
: user.email.slice(0, 1).toUpperCase();
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
const isPathTeamUrl = (teamUrl: string) => {
if (!pathname || !pathname.startsWith(`/t/`)) {
return false;
}
return pathname.split('/')[2] === teamUrl;
};
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
const formatAvatarFallback = (teamName?: string) => {
if (teamName !== undefined) {
return teamName.slice(0, 1).toUpperCase();
}
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
};
const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
if (!team) {
return 'Personal Account';
}
if (team.ownerUserId === user.id) {
return 'Owner';
}
return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
title="Profile Dropdown"
className="relative h-10 w-10 rounded-full"
className="relative flex h-12 flex-row items-center px-2 py-2 focus-visible:ring-0"
>
<Avatar className="h-10 w-10">
<AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar>
{/* Todo */}
<AvatarWithText
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
primaryText={selectedTeam ? selectedTeam.name : user.name}
secondaryText={formatSecondaryAvatarText(selectedTeam)}
rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel>Account</DropdownMenuLabel>
{isUserAdmin && (
<DropdownMenuContent
className={cn('w-full', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
align="end"
forceMount
>
{teams ? (
<>
<DropdownMenuLabel>Personal</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<UserCog className="mr-2 h-4 w-4" />
Admin
<Link href="/">
<AvatarWithText
avatarFallback={formatAvatarFallback()}
primaryText={user.name}
secondaryText={formatSecondaryAvatarText()}
rightSideComponent={
!pathname?.startsWith(`/t/`) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator className="mt-2" />
<DropdownMenuLabel>
<div className="flex flex-row items-center justify-between">
<p>Teams</p>
<div className="flex flex-row space-x-2">
<DropdownMenuItem asChild>
<Button
title="Manage teams"
variant="ghost"
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
asChild
>
<Link href="/settings/teams">
<Settings2 className="h-4 w-4" />
</Link>
</Button>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
title="Manage teams"
variant="ghost"
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
asChild
>
<Link href="/settings/teams?action=add-team">
<Plus className="h-4 w-4" />
</Link>
</Button>
</DropdownMenuItem>
</div>
</div>
</DropdownMenuLabel>
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<Link href={`/t/${team.url}`}>
<AvatarWithText
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
secondaryText={formatSecondaryAvatarText(team)}
rightSideComponent={
isPathTeamUrl(team.url) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</Link>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="cursor-pointer">
<LucideUser className="mr-2 h-4 w-4" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings/security" className="cursor-pointer">
<Lock className="mr-2 h-4 w-4" />
Security
</Link>
</DropdownMenuItem>
{isBillingEnabled && (
<DropdownMenuItem asChild>
<Link href="/settings/billing" className="cursor-pointer">
<CreditCard className="mr-2 h-4 w-4" />
Billing
) : (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
href="/settings/teams?action=add-team"
className="flex items-center justify-between"
>
Create team
<Plus className="ml-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="mr-2 h-4 w-4" />
Themes
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenuRadioItem value="light">
<Sun className="mr-2 h-4 w-4" /> Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
<LuGithub className="mr-2 h-4 w-4" />
Star on Github
</Link>
{isUserAdmin && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link href="/admin">Admin panel</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link href="/settings/profile">User settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{selectedTeam &&
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link href={`/t/${selectedTeam.url}/settings/`}>Team settings</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() =>
void signOut({
className="text-destructive/90 hover:!text-destructive px-4 py-2"
onSelect={async () =>
signOut({
callbackUrl: '/',
})
}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,11 +1,11 @@
'use client';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Lock, User } from 'lucide-react';
import { CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
<Link href="/settings/teams">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
Teams
</Button>
</Link>
<Link href="/settings/security">
<Button
variant="ghost"

View File

@@ -0,0 +1,25 @@
import React from 'react';
export type SettingsHeaderProps = {
title: string;
subtitle: string;
children?: React.ReactNode;
};
export default function SettingsHeader({ children, title, subtitle }: SettingsHeaderProps) {
return (
<>
<div className="flex flex-row items-center justify-between">
<div>
<h3 className="text-lg font-medium">{title}</h3>
<p className="text-muted-foreground mt-2 text-sm">{subtitle}</p>
</div>
{children}
</div>
<hr className="my-4" />
</>
);
}

View File

@@ -1,11 +1,11 @@
'use client';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Lock, User } from 'lucide-react';
import { CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -38,6 +38,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
<Link href="/settings/teams">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
Teams
</Button>
</Link>
<Link href="/settings/security">
<Button
variant="ghost"

View File

@@ -0,0 +1,188 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZAddTeamEmailVerificationMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AddTeamEmailDialogProps = {
teamId: number;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZAddTeamEmailFormSchema = ZAddTeamEmailVerificationMutationSchema.pick({
name: true,
email: true,
});
export type TAddTeamEmailFormSchema = z.infer<typeof ZAddTeamEmailFormSchema>;
export default function AddTeamEmailDialog({ teamId, trigger, ...props }: AddTeamEmailDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const { toast } = useToast();
const form = useForm<TAddTeamEmailFormSchema>({
resolver: zodResolver(ZAddTeamEmailFormSchema),
defaultValues: {
name: '',
email: '',
},
});
const { mutateAsync: addTeamEmailVerification, isLoading } =
trpc.team.addTeamEmailVerification.useMutation();
const onFormSubmit = async ({ name, email }: TAddTeamEmailFormSchema) => {
try {
await addTeamEmailVerification({
teamId,
name,
email,
});
toast({
title: 'Success',
description: 'We have sent a confirmation email for verification.',
duration: 5000,
});
router.refresh();
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
form.setError('email', {
type: 'manual',
message: 'This email is already being used by another team.',
});
return;
}
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to add this email. Please try again later.',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="outline" loading={isLoading} className="bg-background">
<Plus className="-ml-1 mr-1 h-5 w-5" />
Add email
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add team email</DialogTitle>
<DialogDescription className="mt-4">
A verification email will be sent to the provided email.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>Name</FormLabel>
<FormControl>
<Input className="bg-background" placeholder="eg. Legal" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input
className="bg-background"
placeholder="example@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="space-x-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
Add
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CreditCard } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreateTeamDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
name: true,
url: true,
});
export type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
export default function CreateTeamDialog({ trigger, ...props }: CreateTeamDialogProps) {
const router = useRouter();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [open, setOpen] = useState(false);
const [checkoutUrl, setCheckoutUrl] = useState<string | null>(null);
const { toast } = useToast();
const actionSearchParam = searchParams?.get('action');
const form = useForm({
resolver: zodResolver(ZCreateTeamFormSchema),
defaultValues: {
name: '',
url: '',
},
});
const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
const onFormSubmit = async ({ name, url }: TCreateTeamFormSchema) => {
try {
const response = await createTeam({
name,
url,
});
if (!response.paymentRequired) {
toast({
title: 'Success',
description: 'Your team has been successfully created.',
duration: 5000,
});
setOpen(false);
return;
}
setCheckoutUrl(response.checkoutUrl);
router.push(`/settings/teams?tab=pending`);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
form.setError('url', {
type: 'manual',
message: 'This URL is already in use.',
});
return;
}
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to create a team. Please try again later.',
});
}
};
const mapTextToUrl = (text: string) => {
return text.toLowerCase().replace(/\s+/g, '-');
};
useEffect(() => {
if (actionSearchParam === 'add-team') {
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open, setOpen, updateSearchParams]);
useEffect(() => {
setCheckoutUrl(null);
form.reset();
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? <Button variant="secondary">Create team</Button>}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create team</DialogTitle>
<DialogDescription className="mt-4">
Create a team to collaborate with your team members.
</DialogDescription>
</DialogHeader>
{checkoutUrl ? (
<>
<div className="flex h-44 flex-col items-center justify-center rounded-lg bg-gray-50/70">
<CreditCard className="h-8 w-8 text-gray-600" />
<span className="text-muted-foreground text-sm font-medium">
Payment is required to finalise the creation of your team.
</span>
</div>
<DialogFooter className="space-x-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Close
</Button>
<Button type="submit" asChild>
<Link href={checkoutUrl} onClick={() => setOpen(false)} target="_blank">
Checkout
</Link>
</Button>
</DialogFooter>
</>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>Team Name</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onChange={(event) => {
const oldGenericUrl = mapTextToUrl(field.value);
const newGenericUrl = mapTextToUrl(event.target.value);
const urlField = form.getValues('url');
if (urlField === oldGenericUrl) {
form.setValue('url', newGenericUrl);
}
field.onChange(event);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel required>Team URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.url && (
<span className="text-foreground/50 text-xs font-normal">
{field.value
? `${WEBAPP_BASE_URL}/t/${field.value}`
: 'A unique URL to identify your team'}
</span>
)}
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="space-x-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
Create Team
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTeamDialogProps = {
teamId: number;
teamName: string;
trigger?: React.ReactNode;
};
export default function DeleteTeamDialog({ trigger, teamId, teamName }: DeleteTeamDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const { toast } = useToast();
const deleteMessage = `delete ${teamName}`;
const ZDeleteTeamFormSchema = z.object({
teamName: z.literal(deleteMessage, {
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
}),
});
const form = useForm({
resolver: zodResolver(ZDeleteTeamFormSchema),
defaultValues: {
teamName: '',
},
});
const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
const onFormSubmit = async () => {
try {
await deleteTeam({ teamId });
toast({
title: 'Success',
description: 'Your team has been successfully deleted.',
duration: 5000,
});
setOpen(false);
router.push('/settings/teams');
} catch (err) {
const error = AppError.parseError(err);
let toastError: Toast = {
title: 'An unknown error occurred',
variant: 'destructive',
duration: 10000,
description:
'We encountered an unknown error while attempting to delete this team. Please try again later.',
};
if (error.code === 'resource_missing') {
toastError = {
title: 'Unable to delete team',
variant: 'destructive',
duration: 15000,
description:
'Something went wrong while updating the team billing subscription, please contact support.',
};
}
toast(toastError);
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? <Button variant="destructive">Delete team</Button>}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete team</DialogTitle>
<DialogDescription className="mt-4">
Are you sure? This is irreversable.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="space-x-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
Delete
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTeamMemberDialogProps = {
teamId: number;
teamName: string;
teamMemberId: number;
teamMemberName: string;
teamMemberEmail: string;
trigger?: React.ReactNode;
};
export default function DeleteTeamMemberDialog({
trigger,
teamId,
teamName,
teamMemberId,
teamMemberName,
teamMemberEmail,
}: DeleteTeamMemberDialogProps) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const { mutateAsync: deleteTeamMembers, isLoading: isDeletingTeamMember } =
trpc.team.deleteTeamMembers.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'You have successfully removed this user from the team.',
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
duration: 10000,
description:
'We encountered an unknown error while attempting to remove this user. Please try again later.',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamMember && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? <Button variant="secondary">Delete team member</Button>}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription className="mt-4">
You are about to remove the following user from{' '}
<span className="font-semibold">{teamName}</span>.
</DialogDescription>
</DialogHeader>
<div className="bg-accent/50 rounded-lg px-4 py-2">
<div className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{teamMemberName.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col text-sm">
<span className="text-foreground/80 font-semibold">{teamMemberName}</span>
<span className="text-muted-foreground">{teamMemberEmail}</span>
</div>
</div>
</div>
<fieldset disabled={isDeletingTeamMember}>
<DialogFooter className="space-x-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
>
Delete
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,240 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Mail, PlusCircle, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZInviteTeamMembersFormSchema = z
.object({
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
})
.refine(
(schema) => {
const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Members must have unique emails', path: ['members__root'] },
);
export type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
export type InviteTeamMembersDialogProps = {
teamId: number;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export default function InviteTeamMembersDialog({
teamId,
trigger,
...props
}: InviteTeamMembersDialogProps) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const form = useForm<TInviteTeamMembersFormSchema>({
resolver: zodResolver(ZInviteTeamMembersFormSchema),
defaultValues: {
invitations: [
{
email: '',
role: TeamMemberRole.MEMBER,
},
],
},
});
const {
append: appendTeamMemberInvite,
fields: teamMemberInvites,
remove: removeTeamMemberInvite,
} = useFieldArray({
control: form.control,
name: 'invitations',
});
const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation();
const onAddTeamMemberInvite = () => {
appendTeamMemberInvite({
email: '',
role: TeamMemberRole.MEMBER,
});
};
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
try {
await createTeamMemberInvites({
teamId,
invitations,
});
toast({
title: 'Success',
description: 'Team invitations have been sent.',
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to invite team members. Please try again later.',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? <Button variant="secondary">Invite member</Button>}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite team members</DialogTitle>
<DialogDescription className="mt-4">
An email containing an invitation will be sent to each member.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="space-y-4">
{teamMemberInvites.map((teamMemberInvite, index) => (
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
<FormField
control={form.control}
name={`invitations.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email address</FormLabel>}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`invitations.${index}.role`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Role</FormLabel>}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(TeamMemberRole).map((role) => (
<SelectItem key={role} value={role}>
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={teamMemberInvites.length === 1}
onClick={() => removeTeamMemberInvite(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onAddTeamMemberInvite()}
>
<PlusCircle className="mr-2 h-4 w-4" />
Add more members
</Button>
<DialogFooter className="space-x-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
Invite
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import type { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LeaveTeamDialogProps = {
teamId: number;
teamName: string;
role: TeamMemberRole;
trigger?: React.ReactNode;
};
export default function LeaveTeamDialog({ trigger, teamId, teamName, role }: LeaveTeamDialogProps) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const { mutateAsync: leaveTeam, isLoading: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'You have successfully left this team.',
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
duration: 10000,
description:
'We encountered an unknown error while attempting to leave this team. Please try again later.',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLeavingTeam && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? <Button variant="destructive">Leave team</Button>}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription className="mt-4">
You are about to leave the following team.
</DialogDescription>
</DialogHeader>
<div className="bg-accent/50 rounded-lg px-4 py-2">
<div className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{teamName.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col text-sm">
<span className="text-foreground/80 font-semibold">{teamName}</span>
<span className="text-muted-foreground">{TEAM_MEMBER_ROLE_MAP[role]}</span>
</div>
</div>
</div>
<fieldset disabled={isLeavingTeam}>
<DialogFooter className="space-x-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isLeavingTeam}
onClick={async () => leaveTeam({ teamId })}
>
Leave
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
}

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