Compare commits

...

76 Commits

Author SHA1 Message Date
22a37409c1 v1.12.0-rc.3 2025-06-10 12:49:37 +10:00
50605d5912 chore: add translations (#1830)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-06-10 12:30:36 +10:00
4609fc852d chore: extract translations 2025-06-10 11:52:59 +10:00
e6dc237ad2 feat: add organisations (#1820) 2025-06-10 11:49:52 +10:00
0b37f19641 chore: add translations (#1774) 2025-06-09 16:00:03 +10:00
64c6a51e04 v1.12.0-rc.2 2025-06-07 02:25:14 +10:00
d1eddb02c4 fix: add missing awaits for font normalization 2025-06-07 02:24:59 +10:00
60a623fafd v1.12.0-rc.1 2025-06-07 00:56:40 +10:00
6059b79a8e fix: type error 2025-06-07 00:56:33 +10:00
c73d61955b v1.12.0-rc.0 2025-06-07 00:47:49 +10:00
7c3ca72359 fix: track uninserted fields for multisign 2025-06-07 00:44:41 +10:00
55c8632620 feat: password reauthentication for documents and recipients (#1827)
Adds password reauthentication to our existing reauth providers,
additionally swaps from an exclusive provider to an inclusive type where
multiple methods can be selected to offer a this or that experience.
2025-06-07 00:27:19 +10:00
ce66da0055 feat: multisign embedding (#1823)
Adds the ability to use a multisign embedding for cases where multiple
documents need to be signed in a convenient manner.
2025-06-05 12:58:52 +10:00
695ed418e2 fix: documents failing to seal (#1821)
During our field rework that makes fields appear
more accurately between signing and the completed pdf we swapped to
using text fields. Unfortunately as part of that we dropped using the
Noto font for the text field causing ANSI encoding issues when
encountering certain characters.

This change restores the font and handles a nasty issue we had with our
form flattening reverting our selected font.
2025-06-04 23:29:36 +10:00
93aece9644 chore: dependency updates (#1808) 2025-05-22 14:30:22 +10:00
abd4fddf31 chore: test reo integration (#1806)
---
name: Pull Request
about: Submit changes to the project for review and inclusion
---

## Description

Experimental Short-Term Reo Integration
2025-05-21 15:24:46 +02:00
44bc769e60 v1.11.1 2025-05-20 22:37:46 +10:00
c8f80f7be0 fix: reverse original document logic for api endpoint 2025-05-20 22:37:17 +10:00
8540f24de0 v1.11.0 2025-05-19 15:44:10 +10:00
67203d4bd7 fix: show powered by logic (#1801)
Previous powered by display logic was incorrect, likely due to a merge
conflict.
2025-05-19 14:31:24 +10:00
9d1e638f0f fix: pending tooltip click triggers field (#1800)
Makes it so clicking on the pending field tooltip will trigger the
underlying field it refers to on click if the field can be found within
the DOM.
2025-05-19 10:27:13 +10:00
bd64ad9fef fix: improve multiselect for webhook triggers (#1795)
Replaces https://github.com/documenso/documenso/pull/1660 with the same
code but targeting our main branch.

## Demo

![CleanShot 2025-02-18 at 18 01
05](https://github.com/user-attachments/assets/5afeab95-1a80-4d54-b845-b32cb2e33266)
2025-05-15 13:01:45 +10:00
99b0ad574e feat: bulk add fields (#1683)
## Demo

![CleanShot 2025-03-04 at 02 17
47](https://github.com/user-attachments/assets/2cffaee3-9933-49e9-bdab-eadfd4c35030)

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2025-05-14 19:35:32 +00:00
9594e1fee8 chore: minor ui fixes (#1793) 2025-05-14 20:08:03 +10:00
5e3a2b8f76 fix: allow prefilling date field (#1794)
Allows the prefilling of date fields when creating a document from a
template.

Current implementation is super dirty and should be replaced asap.
2025-05-14 20:06:53 +10:00
f928503a33 chore: update dropdown icons (#1790)
### Before

![CleanShot 2025-05-12 at 11 11
05@2x](https://github.com/user-attachments/assets/af2a60bf-9676-405d-8c3d-e6b2256b53ae)

### After

![CleanShot 2025-05-12 at 11 10
25@2x](https://github.com/user-attachments/assets/aec67e9c-f0f2-4b0d-9baa-7aa327680cf1)
2025-05-14 16:44:13 +10:00
c389670785 fix: trigger webhook for duplicated documents (#1789) 2025-05-14 16:43:31 +10:00
99ad2eb645 fix: allow download of original document via api (#1788) 2025-05-14 08:22:11 +10:00
2f48679b0b fix: make lang cookie httpOnly (#1783) 2025-05-08 15:59:43 +10:00
e40c5d9d24 v1.10.3 2025-05-03 09:23:25 +10:00
ab323f149f fix: resolve issue with uploading templates 2025-05-03 09:23:17 +10:00
bf1c1ff9dc v1.10.2 2025-05-03 08:11:27 +10:00
516e237966 fix: resolve issue with uploading templates 2025-05-03 08:09:44 +10:00
ac7d24eb12 v1.10.1 2025-05-03 07:39:19 +10:00
0931c472a7 fix: resolve issue with uploading templates 2025-05-03 07:38:48 +10:00
8c9dd5e372 v1.10.0 2025-05-02 12:03:08 +10:00
e108da546d fix: incorrect data for postMessage 2025-05-02 10:50:13 +10:00
17370749b4 feat: add folders (#1711) 2025-05-02 02:46:59 +10:00
12ada567f5 feat: embed authoring part two (#1768) 2025-05-01 23:32:56 +10:00
bdb0b0ea88 feat: certificate qrcode (#1755)
Adds document access tokens and QR code functionality to enable secure
document sharing via URLs. It includes a new document access page that
allows viewing and downloading documents through tokenized links.
2025-04-28 11:30:09 +10:00
6a41a37bd4 feat: download original documents (#1742)
## Preview
![CleanShot 2025-04-10 at 14 26
11@2x](https://github.com/user-attachments/assets/d4984d85-ab40-4d38-8d5c-a1085bde21a2)
2025-04-25 22:44:03 +10:00
d78cfec00e fix: branding logos (#1759) 2025-04-24 16:15:06 +10:00
f0dcf7e9bf fix: signing volume query (#1753)
This pull request updates the implementation of the admin leaderboard,
enhancing data handling and improving type safety. It introduces clearer
differentiation between users and teams, adds additional fields to track
more relevant information, and refactors the querying logic to optimize
performance and maintainability.
2025-04-24 16:14:38 +10:00
6540291055 feat: migrate webhook execution to background jobs (#1694) 2025-04-24 06:00:53 +00:00
193325717d fix: rework fields (#1697)
Rework:
- Field styling to improve visibility
- Field insertions, better alignment, centering and overflows

## Changes

General changes:

- Set default text alignment to left if no meta found
- Reduce borders and rings around fields to allow smaller fields
- Removed lots of redundant duplicated code surrounding field rendering
- Make fields more consistent across viewing, editing and signing
- Add more transparency to fields to allow users to see under fields
- No more optional/required/etc colors when signing, required fields
will be highlighted as orange when form is "validating"

Highlighted internal changes:

- Utilize native PDF fields to insert text, instead of drawing text 
- Change font auto scaling to only apply to when the height overflows
AND no custom font is set

⚠️ Multiline changes:

Multi line is enabled for a field under these conditions

1. Field content exceeds field width
2. Field includes a new line
3. Field type is TEXT

## [BEFORE] Field UI Signing 


![image](https://github.com/user-attachments/assets/ea002743-faeb-477c-a239-3ed240b54f55)

## [AFTER] Field UI Signing 


![image](https://github.com/user-attachments/assets/0f8eb252-4cf3-4d96-8d4f-cd085881b78c)

## [BEFORE] Signing a checkbox


![image](https://github.com/user-attachments/assets/4567d745-e1da-4202-a758-5d3c178c930e)

![image](https://github.com/user-attachments/assets/c25068e7-fe80-40f5-b63a-e8a0d4b38b6c)

## [AFTER] Signing a checkbox


![image](https://github.com/user-attachments/assets/effa5e3d-385a-4c35-bc8a-405386dd27d6)

![image](https://github.com/user-attachments/assets/64be34a9-0b32-424d-9264-15361c03eca5)

## [BEFORE] What a 2nd recipient sees once someone else signed a
document


![image](https://github.com/user-attachments/assets/21c21ae2-fc62-4ccc-880a-46aab012aa70)

## [AFTER] What a 2nd recipient sees once someone else signed a document


![image](https://github.com/user-attachments/assets/ae51677b-f1d5-4008-a7fd-756533166542)

## **[BEFORE]** Inserting fields


![image](https://github.com/user-attachments/assets/1a8bb8da-9a15-4deb-bc28-eb349414465c)

## **[AFTER]** Inserting fields


![image](https://github.com/user-attachments/assets/c52c5238-9836-45aa-b8a4-bc24a3462f40)

## Overflows, multilines and field alignments testing

Debugging borders:
- Red border = The original field placement without any modifications
- Blue border = The available space to overflow

### Single line overflows and field alignments 

This is left aligned fields, overflow will always go to the end of the
page and will not wrap


![image](https://github.com/user-attachments/assets/47003658-783e-4f9c-adbf-c4686804d98f)

This is center aligned fields, the max width is the closest edge to the
page * 2


![image](https://github.com/user-attachments/assets/05a38093-75d6-4600-bae2-21ecff63e115)

This is right aligned text, the width will extend all the way to the
left hand side of the page


![image](https://github.com/user-attachments/assets/6a9d84a8-4166-4626-9fb3-1577fac2571e)

### Multiline line overflows and field alignments 

These are text fields that can be overflowed


![image](https://github.com/user-attachments/assets/f7b5456e-2c49-48b2-8d4c-ab1dc3401644)

Another example of left aligned text overflows with more text


![image](https://github.com/user-attachments/assets/3db6b35e-4c8d-4ffe-8036-0da760d9c167)
2025-04-23 21:40:42 +10:00
b94645a451 fix: optional fields being required in direct links (#1752) 2025-04-21 16:34:29 +10:00
7e6704faae chore: update tests 2025-04-21 16:23:50 +10:00
cf17fc61bc chore: update tests 2025-04-21 16:07:19 +10:00
6df8b3aac8 chore: update ci 2025-04-21 14:29:40 +10:00
fdb31772db chore: update tests 2025-04-21 14:13:12 +10:00
a3dfd81870 chore: update playwright config 2025-04-21 13:27:19 +10:00
755ef697ba chore: update playwright config 2025-04-21 13:03:29 +10:00
37cc41d713 fix: skip immediate expiration presign test 2025-04-21 12:41:38 +10:00
dd2ef3a657 v1.10.0-rc.5 2025-04-17 23:01:43 +10:00
435b3ca4f8 chore: remove legacy document update route (#1751)
Remove deprecated route
2025-04-17 16:36:10 +10:00
278cd8a9de fix: always show ip and useragent in certificate 2025-04-17 12:55:03 +10:00
f1526315f5 feat: limit free teams platform plan (#1673)
This pull request removes the `id` field from
`IsDocumentPlatformOptions` in `is-document-platform.ts` and updates the
billing logic in `create-team.ts`: platform plan users create their
first team free, but pay for subsequent teams; non-platform users need
an active team subscription if billing is enabled.
2025-04-15 21:32:15 +10:00
353a7e8e0d fix: dynamic route for team transfer (#1730)
fix: dynamic route handling for /team/verify/transfer/:token
2025-04-15 21:30:44 +10:00
34b2504268 chore: husky (#1706) 2025-04-15 21:29:03 +10:00
566abda36b chore: update render build command (#1748) 2025-04-15 19:06:06 +10:00
9121a062b3 chore: add docs for authoring 2025-04-14 11:31:54 +10:00
e613e0e347 feat: support embedded authoring for creation (#1741)
Adds support for creating documents and templates
using our embed components.

Support is super primitive at the moment and is being polished.
2025-04-11 00:20:39 +10:00
95aae52fa4 chore: add translations (#1715)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-04-10 12:24:07 +10:00
5958f38719 chore: set the default value on the top (#1734) 2025-04-08 23:35:32 +10:00
419bc02171 docs: prefill fields (#1688) 2025-04-04 00:03:37 +11:00
5e4956f3a2 fix: zero month addition (#1733)
- Add zero month at the begining of each metric on the open page
2025-04-01 11:12:41 +00:00
da71613c9f v1.10.0-rc.4 2025-03-31 20:02:22 +11:00
4d6efe091e fix: pass document meta to readonly field component (#1737)
## Description

Previously we weren't passing the DocumentMeta to our readonly field
component which is used for displaying completed fields by other
recipients.

Due to this dates that were not using the default format were displaying
as invalid date adding confusion to the signing process.

## Related Issue

Reported via support email.

## Changes Made

- Pass the document meta to the readonly field component.
- Support showing completed fields within the embedding UI.

## Testing Performed

- Manual testing
2025-03-31 17:14:56 +11:00
7e6ac4db40 fix: direct template redirects (#1727) 2025-03-28 14:45:54 +11:00
a87af910c7 v1.10.0-rc.2 2025-03-28 01:50:59 +11:00
e37b005d7f chore: update dockerfile 2025-03-28 01:28:49 +11:00
73f8518b47 chore: update tests 2025-03-28 01:21:48 +11:00
ac3deb113e chore: update ci 2025-03-27 22:49:59 +11:00
c82388c40a fix: remove console.log embed document completed (#1723) 2025-03-25 16:36:52 +02:00
31be548939 fix: duplicate webhook calls on document complete (#1721)
Fix webhooks being sent twice due to duplicate frontend calls

Updated the assistant confirmation dialog so the next signer is always
visible (if dictate is enabled). Because if the form is invalid (due to
no name) there is no visual queue that the form is invalid (since it's
hidden)

## Notes

Didn't bother to remove the weird assistants form since it currently
works for now


![image](https://github.com/user-attachments/assets/47910fec-e05e-486a-a61d-16078d948893)

## Tests

- Currently running locally
- Tested webhooks via network tab and via webhook.site
2025-03-25 21:59:13 +11:00
063fd32f18 feat: add signature configurations (#1710)
Add ability to enable or disable allowed signature types:
- Drawn
- Typed
- Uploaded

**Tabbed style signature dialog**

![image](https://github.com/user-attachments/assets/a816fab6-b071-42a5-bb5c-6d4a2572431e)

**Document settings**

![image](https://github.com/user-attachments/assets/f0c1bff1-6be1-4c87-b384-1666fa25d7a6)

**Team preferences**

![image](https://github.com/user-attachments/assets/8767b05e-1463-4087-8672-f3f43d8b0f2c)

- Add multiselect to select allowed signatures in document and templates
settings tab
- Add multiselect to select allowed signatures in teams preferences
- Removed "Enable typed signatures" from document/template edit page
- Refactored signature pad to use tabs instead of an all in one
signature pad

Added E2E tests to check settings are applied correctly for documents
and templates
2025-03-24 17:13:11 +11:00
842 changed files with 85035 additions and 38143 deletions

View File

@ -1,4 +1,7 @@
You are an expert in TypeScript, Node.js, Remix, React, Shadcn UI and Tailwind.
Code Style and Structure:
- Write concise, technical TypeScript code with accurate examples
- Use functional and declarative programming patterns; avoid classes
- Prefer iteration and modularization over code duplication
@ -6,20 +9,25 @@ Code Style and Structure:
- Structure files: exported component, subcomponents, helpers, static content, types
Naming Conventions:
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
- Favor named exports for components
TypeScript Usage:
- Use TypeScript for all code; prefer interfaces over types
- Avoid enums; use maps instead
- Use TypeScript for all code; prefer types over interfaces
- Use functional components with TypeScript interfaces
Syntax and Formatting:
- Use the "function" keyword for pure functions
- Create functions using `const fn = () => {}`
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
- Use declarative JSX
- Never use 'use client'
- Never use 1 line if statements
Error Handling and Validation:
- Prioritize error handling: handle errors and edge cases early
- Use early returns and guard clauses
- Implement proper error logging and user-friendly messages
@ -28,21 +36,40 @@ Error Handling and Validation:
- Use error boundaries for unexpected errors
UI and Styling:
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
- Implement responsive design with Tailwind CSS; use a mobile-first approach
- When using Lucide icons, prefer the longhand names, for example HomeIcon instead of Home
Performance Optimization:
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC)
- Wrap client components in Suspense with fallback
- Use dynamic loading for non-critical components
- Optimize images: use WebP format, include size data, implement lazy loading
React forms
Key Conventions:
- Use 'nuqs' for URL search parameter state management
- Optimize Web Vitals (LCP, CLS, FID)
- Limit 'use client':
- Favor server components and Next.js SSR
- Use only for Web API access in small components
- Avoid for data fetching or state management
- Use zod for form validation react-hook-form for forms
- Look at TeamCreateDialog.tsx as an example of form usage
- Use <Form> <FormItem> elements, and also wrap the contents of form in a fieldset which should have the :disabled attribute when the form is loading
Follow Next.js docs for Data Fetching, Rendering, and Routing
TRPC Specifics
- Every route should be in it's own file, example routers/teams/create-team.ts
- Every route should have a types file associated with it, example routers/teams/create-team.types.ts. These files should have the OpenAPI meta, and request/response zod schemas
- The request/response schemas should be named like Z[RouteName]RequestSchema and Z[RouteName]ResponseSchema
- Use create-team.ts and create-team.types.ts as an example when creating new routes.
- When creating the OpenAPI meta, only use GET and POST requests, do not use any other REST methods
- Deconstruct the input argument on it's one line of code.
Toast usage
- Use the t`string` macro from @lingui/react/macro to display toast messages
Remix/ReactRouter Usage
- Use (params: Route.Params) to get the params from the route
- Use (loaderData: Route.LoaderData) to get the loader data from the route
- When using loaderdata, deconstruct the data you need from the loader data inside the function body
- Do not use json() to return data, directly return the data
Translations
- Use <Trans>string</Trans> to display translations in jsx code, this should be imported from @lingui/react/macro
- Use the t`string` macro from @lingui/react/macro to display translations in typescript code
- t should be imported as const { t } = useLingui() where useLingui is imported from @lingui/react/macro
- String in constants should be using the t`string` macro

View File

@ -1,23 +0,0 @@
name: Cache production build binaries
description: 'Cache or restore if necessary'
inputs:
node_version:
required: false
default: v22.x
runs:
using: 'composite'
steps:
- name: Cache production build
uses: actions/cache@v3
id: production-build-cache
with:
path: |
${{ github.workspace }}/apps/web/.next
**/.turbo/**
**/dist/**
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
restore-keys: prod-build-
- run: npm run build
shell: bash

View File

@ -26,7 +26,8 @@ jobs:
- name: Copy env
run: cp .env.example .env
- uses: ./.github/actions/cache-build
- name: Build app
run: npm run build
build_docker:
name: Build Docker Image

View File

@ -1,29 +0,0 @@
name: cleanup caches by a branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge

View File

@ -10,7 +10,7 @@ on:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
@ -30,7 +30,8 @@ jobs:
- uses: ./.github/actions/node-install
- uses: ./.github/actions/cache-build
- name: Build app
run: npm run build
- name: Initialize CodeQL
uses: github/codeql-action/init@v3

View File

@ -28,7 +28,11 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
- uses: ./.github/actions/cache-build
- name: Build app
run: npm run build
- name: Install playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run ci

View File

@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run commitlint -- $1

View File

@ -1,6 +1,3 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"

2
.npmrc
View File

@ -1 +1,3 @@
auto-install-peers = true
legacy-peer-deps = true
prefer-dedupe = true

View File

@ -1,3 +1,5 @@
import nextra from 'nextra';
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: [
@ -9,9 +11,10 @@ const nextConfig = {
],
};
const withNextra = require('nextra')({
const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.tsx',
codeHighlight: true,
});
module.exports = withNextra(nextConfig);
export default withNextra(nextConfig);

View File

@ -15,7 +15,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "14.2.6",
"next": "14.2.28",
"next-plausible": "^3.12.0",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",

View File

@ -6,5 +6,6 @@
"solid": "Solid Integration",
"preact": "Preact Integration",
"angular": "Angular Integration",
"css-variables": "CSS Variables"
}
"css-variables": "CSS Variables",
"authoring": "Authoring"
}

View File

@ -0,0 +1,167 @@
---
title: Authoring
description: Learn how to use embedded authoring to create documents and templates in your application
---
# Embedded Authoring
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
## How Embedded Authoring Works
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
## Creating Documents with Embedded Authoring
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
```jsx
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
const DocumentCreator = () => {
// You'll need to obtain a presign token using your API key
const presignToken = 'YOUR_PRESIGN_TOKEN';
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateDocument
presignToken={presignToken}
externalId="order-12345"
onDocumentCreated={(data) => {
console.log('Document created with ID:', data.documentId);
console.log('External reference ID:', data.externalId);
}}
/>
</div>
);
};
```
## Obtaining a Presign Token
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
You can create a presign token by making a request to:
```
POST /api/v2-beta/embedding/create-presign-token
```
This API endpoint requires authentication with your Documenso API key. The token has a default expiration of 1 hour, but you can customize this duration based on your security requirements.
You can find more details on this request at our [API Documentation](https://openapi.documenso.com/reference#tag/embedding)
## Configuration Options
The `EmbedCreateDocument` component accepts several configuration options:
| Option | Type | Description |
| ------------------ | ------- | ------------------------------------------------------------------ |
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
| `externalId` | string | Optional reference ID from your system to link with the document. |
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
| `css` | string | Optional custom CSS to style the embedded component. |
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
| `className` | string | Optional CSS class name for the iframe. |
## Feature Toggles
You can customize the authoring experience by enabling or disabling specific features:
```jsx
<EmbedCreateDocument
presignToken="YOUR_PRESIGN_TOKEN"
features={{
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureCommunication: true,
}}
/>
```
## Handling Document Creation Events
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
```jsx
<EmbedCreateDocument
presignToken="YOUR_PRESIGN_TOKEN"
externalId="order-12345"
onDocumentCreated={(data) => {
// Navigate to a success page
navigate(`/documents/success?id=${data.documentId}`);
// Or update your database with the document ID
updateOrderDocument(data.externalId, data.documentId);
}}
/>
```
## Styling the Embedded Component
You can customize the appearance of the embedded component using standard CSS classes:
```jsx
<EmbedCreateDocument
className="h-screen w-full rounded-lg border-none shadow-md"
presignToken="YOUR_PRESIGN_TOKEN"
css={`
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
`}
cssVars={{
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
}}
/>
```
## Complete Integration Example
Here's a complete example of integrating document creation in a React application:
```tsx
import { useState } from 'react';
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
function DocumentCreator() {
// In a real application, you would fetch this token from your backend
// using your API key at /api/v2-beta/embedding/create-presign-token
const presignToken = 'YOUR_PRESIGN_TOKEN';
const [documentId, setDocumentId] = useState<number | null>(null);
if (documentId) {
return (
<div>
<h2>Document Created Successfully!</h2>
<p>Document ID: {documentId}</p>
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
</div>
);
}
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateDocument
presignToken={presignToken}
externalId="order-12345"
onDocumentCreated={(data) => {
setDocumentId(data.documentId);
}}
/>
</div>
);
}
export default DocumentCreator;
```
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.

View File

@ -169,6 +169,19 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
## Embedded Authoring
In addition to embedding signing experiences, Documenso now supports **embedded authoring**, allowing your users to create documents and templates directly within your application.
With embedded authoring, you can:
- Create new documents with custom fields
- Configure document properties and settings
- Set up recipients and signing workflows
- Customize the authoring experience
For detailed implementation instructions and code examples, see our [Embedded Authoring](/developers/embedding/authoring) guide.
## Related
- [React Integration](/developers/embedding/react)
@ -178,3 +191,4 @@ If you're using **web components**, the integration process is slightly differen
- [Preact Integration](/developers/embedding/preact)
- [Angular Integration](/developers/embedding/angular)
- [CSS Variables](/developers/embedding/css-variables)
- [Embedded Authoring](/developers/embedding/authoring)

View File

@ -532,3 +532,93 @@ Replace the `text` value with the corresponding field type:
- For the `SELECT` field it should be `select`. (check this before merge)
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
## Pre-fill Fields On Document Creation
The API allows you to pre-fill fields on document creation. This is useful when you want to create a document from an existing template and pre-fill the fields with specific values.
To pre-fill a field, you need to make a `POST` request to the `/api/v1/templates/{templateId}/generate-document` endpoint with the field information. Here's an example:
```json
{
"title": "my-document.pdf",
"recipients": [
{
"id": 3,
"name": "Example User",
"email": "example@documenso.com",
"signingOrder": 1,
"role": "SIGNER"
}
],
"prefillFields": [
{
"id": 21,
"type": "text",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "my-value"
},
{
"id": 22,
"type": "number",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "123"
},
{
"id": 23,
"type": "checkbox",
"label": "my-label",
"placeholder": "my-placeholder",
"value": ["option-1", "option-2"]
}
]
}
```
Check out the endpoint in the [API V1 documentation](https://app.documenso.com/api/v1/openapi#:~:text=/%7BtemplateId%7D/-,generate,-%2Ddocument).
### API V2
For API V2, you need to make a `POST` request to the `/api/v2-beta/template/use` endpoint with the field(s) information. Here's an example:
```json
{
"templateId": 111,
"recipients": [
{
"id": 3,
"name": "Example User",
"email": "example@documenso.com",
"signingOrder": 1,
"role": "SIGNER"
}
],
"prefillFields": [
{
"id": 21,
"type": "text",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "my-value"
},
{
"id": 22,
"type": "number",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "123"
},
{
"id": 23,
"type": "checkbox",
"label": "my-label",
"placeholder": "my-placeholder",
"value": ["option-1", "option-2"]
}
]
}
```
Check out the endpoint in the [API V2 documentation](https://openapi.documenso.com/reference#tag/template/POST/template/use).

View File

@ -1,5 +1,3 @@
'use client';
import React from 'react';
import NextPlausibleProvider from 'next-plausible';

View File

@ -19,6 +19,22 @@ const themeConfig: DocsThemeConfig = {
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<script
dangerouslySetInnerHTML={{
__html: `
!function(){
if (location.hostname === 'localhost') return;
var e="6c236490c9a68c1",
t=function(){Reo.init({ clientID: e })},
n=document.createElement("script");
n.src="https://static.reo.dev/"+e+"/reo.js";
n.defer=true;
n.onload=t;
document.head.appendChild(n);
}();
`,
}}
/>
</>
);
},

View File

@ -0,0 +1,54 @@
import { DateTime } from 'luxon';
export interface TransformedData {
labels: string[];
datasets: Array<{
label: string;
data: number[];
}>;
}
export function addZeroMonth(transformedData: TransformedData): TransformedData {
const result = {
labels: [...transformedData.labels],
datasets: transformedData.datasets.map((dataset) => ({
label: dataset.label,
data: [...dataset.data],
})),
};
if (result.labels.length === 0) {
return result;
}
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
return result;
}
try {
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
if (!firstMonth.isValid) {
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
for (const format of formats) {
firstMonth = DateTime.fromFormat(result.labels[0], format);
if (firstMonth.isValid) break;
}
if (!firstMonth.isValid) {
console.warn(`Could not parse date: "${result.labels[0]}"`);
return transformedData;
}
}
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
result.labels.unshift(zeroMonth);
result.datasets.forEach((dataset) => {
dataset.data.unshift(0);
});
return result;
} catch (error) {
return transformedData;
}
}

View File

@ -3,6 +3,8 @@ import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { addZeroMonth } from '../add-zero-month';
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
.selectFrom('Document')
@ -35,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetCompletedDocumentsMonthlyResult = Awaited<

View File

@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { addZeroMonth } from '../add-zero-month';
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
.selectFrom('Recipient')
@ -34,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetSignerConversionMonthlyResult = Awaited<

View File

@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { addZeroMonth } from '../add-zero-month';
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
.selectFrom('User')
@ -32,7 +34,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;

View File

@ -1,5 +1,7 @@
import { DateTime } from 'luxon';
import { addZeroMonth } from './add-zero-month';
type MetricKeys = {
stars: number;
forks: number;
@ -37,31 +39,77 @@ export function transformData({
data: DataEntry;
metric: MetricKey;
}): TransformData {
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);
try {
if (!data || Object.keys(data).length === 0) {
return {
labels: [],
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
};
}
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
});
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
try {
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);
const labels = sortedEntries.map(([date]) => {
const [year, month] = date.split('-');
const dateTime = DateTime.fromObject({
year: Number(year),
month: Number(month),
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
return 0;
}
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
} catch (error) {
console.error('Error sorting entries:', error);
return 0;
}
});
return dateTime.toFormat('MMM yyyy');
});
return {
labels,
datasets: [
{
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
data: sortedEntries.map(([_, stats]) => stats[metric]),
},
],
};
const labels = sortedEntries.map(([date]) => {
try {
const [year, month] = date.split('-');
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
console.warn(`Invalid date format: ${date}`);
return date;
}
const dateTime = DateTime.fromObject({
year: Number(year),
month: Number(month),
});
if (!dateTime.isValid) {
console.warn(`Invalid DateTime object for: ${date}`);
return date;
}
return dateTime.toFormat('MMM yyyy');
} catch (error) {
console.error('Error formatting date:', error, date);
return date;
}
});
const transformedData = {
labels,
datasets: [
{
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
data: sortedEntries.map(([_, stats]) => {
const value = stats[metric];
return typeof value === 'number' && !isNaN(value) ? value : 0;
}),
},
],
};
return addZeroMonth(transformedData);
} catch (error) {
return {
labels: [],
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
};
}
}
// To be on the safer side

View File

@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.5.0",
"next": "14.2.6"
"next": "14.2.28"
},
"devDependencies": {
"@types/node": "^20",

View File

@ -1,7 +1,7 @@
#!/usr/bin/env sh
#!/usr/bin/env bash
# Exit on error.
set -eo pipefail
set -e
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.."

View File

@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/create-admin-organisation.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
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 OrganisationCreateDialogProps = {
trigger?: React.ReactNode;
ownerUserId: number;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateAdminOrganisationFormSchema = ZCreateAdminOrganisationRequestSchema.shape.data.pick({
name: true,
});
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateAdminOrganisationFormSchema>;
export const AdminOrganisationCreateDialog = ({
trigger,
ownerUserId,
...props
}: OrganisationCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const form = useForm({
resolver: zodResolver(ZCreateAdminOrganisationFormSchema),
defaultValues: {
name: '',
},
});
const { mutateAsync: createOrganisation } = trpc.admin.organisation.create.useMutation();
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
try {
const { organisationId } = await createOrganisation({
ownerUserId,
data: {
name,
},
});
await navigate(`/admin/organisations/${organisationId}`);
setOpen(false);
toast({
title: t`Success`,
description: t`Organisation created`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
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 className="flex-shrink-0" variant="secondary">
<Trans>Create organisation</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create organisation</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create an organisation for this user</Trans>
</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>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="neutral">
<AlertDescription className="mt-0">
<Trans>
You will need to configure any claims or subscription after creating this
organisation
</Trans>
</AlertDescription>
</Alert>
{/* <FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default claim ID</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
<Trans>Leave blank to use the default free claim</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-button"
loading={form.formState.isSubmitting}
>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -68,7 +68,7 @@ export function AssistantConfirmationDialog({
});
const onOpenChange = () => {
if (form.formState.isSubmitting) {
if (isSubmitting) {
return;
}
@ -77,32 +77,25 @@ export function AssistantConfirmationDialog({
email: defaultNextSigner?.email ?? '',
});
setIsEditingNextSigner(false);
onClose();
};
const onFormSubmit = async (data: TNextSignerFormSchema) => {
if (allowDictateNextSigner && data.name && data.email) {
await onConfirm({
name: data.name,
email: data.email,
});
} else {
await onConfirm();
const handleSubmit = () => {
// Validate the form and submit it if dictate signer is enabled.
if (allowDictateNextSigner) {
void form.handleSubmit(onConfirm)();
return;
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
onConfirm();
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isSubmitting}
className="border-none p-0"
>
<form>
<fieldset disabled={isSubmitting} className="border-none p-0">
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
@ -118,7 +111,7 @@ export function AssistantConfirmationDialog({
<div className="mt-4 flex flex-col gap-4">
{allowDictateNextSigner && (
<div className="space-y-4">
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
@ -156,6 +149,7 @@ export function AssistantConfirmationDialog({
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
@ -190,27 +184,17 @@ export function AssistantConfirmationDialog({
</div>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={form.formState.isSubmitting}
>
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
type="button"
variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={form.formState.isSubmitting || !isNextSignerValid}
loading={form.formState.isSubmitting}
disabled={isSubmitting}
onClick={handleSubmit}
loading={isSubmitting}
>
{form.formState.isSubmitting ? (
<Trans>Submitting...</Trans>
) : hasUninsertedFields ? (
<Trans>Proceed</Trans>
) : (
<Trans>Continue</Trans>
)}
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
</Button>
</DialogFooter>
</fieldset>

View File

@ -0,0 +1,90 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { z } from 'zod';
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
import { trpc } from '@documenso/trpc/react';
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
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';
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
export const ClaimCreateDialog = () => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: createClaim, isPending } = trpc.admin.claims.create.useMutation({
onSuccess: () => {
toast({
title: t`Subscription claim created successfully.`,
});
setOpen(false);
},
onError: () => {
toast({
title: t`Failed to create subscription claim.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create claim</Trans>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Create Subscription Claim</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fill in the details to create a new subscription claim.</Trans>
</DialogDescription>
</DialogHeader>
<SubscriptionClaimForm
subscriptionClaim={{
...generateDefaultSubscriptionClaim(),
}}
onFormSubmit={createClaim}
formSubmitTrigger={
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Create Claim</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,96 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
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 ClaimDeleteDialogProps = {
claimId: string;
claimName: string;
claimLocked: boolean;
trigger: React.ReactNode;
};
export const ClaimDeleteDialog = ({
claimId,
claimName,
claimLocked,
trigger,
}: ClaimDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: deleteClaim, isPending } = trpc.admin.claims.delete.useMutation({
onSuccess: () => {
toast({
title: t`Subscription claim deleted successfully.`,
});
setOpen(false);
},
onError: (err) => {
console.error(err);
toast({
title: t`Failed to delete subscription claim.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete Subscription Claim</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Are you sure you want to delete the following claim?</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{claimLocked ? <Trans>This claim is locked and cannot be deleted.</Trans> : claimName}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
{!claimLocked && (
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () => deleteClaim({ id: claimId })}
>
<Trans>Delete</Trans>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,92 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
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';
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type ClaimUpdateDialogProps = {
claim: TFindSubscriptionClaimsResponse['data'][number];
trigger: React.ReactNode;
};
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
onSuccess: () => {
toast({
title: t`Subscription claim updated successfully.`,
});
setOpen(false);
},
onError: () => {
toast({
title: t`Failed to update subscription claim.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Update Subscription Claim</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Modify the details of the subscription claim.</Trans>
</DialogDescription>
</DialogHeader>
<SubscriptionClaimForm
subscriptionClaim={claim}
onFormSubmit={async (data) =>
await updateClaim({
id: claim.id,
data,
})
}
formSubmitTrigger={
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};

View File

@ -28,7 +28,6 @@ type DocumentDeleteDialogProps = {
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};

View File

@ -16,7 +16,7 @@ import {
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: number;
@ -34,7 +34,7 @@ export const DocumentDuplicateDialog = ({
const { toast } = useToast();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
{
@ -52,7 +52,7 @@ export const DocumentDuplicateDialog = ({
}
: undefined;
const documentsPath = formatDocumentsPath(team?.url);
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({

View File

@ -1,124 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DocumentMoveDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => {
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: _(msg`Error`),
description: error.message || _(msg`An error occurred while moving the document.`),
variant: 'destructive',
duration: 7500,
});
},
});
const onMove = async () => {
if (!selectedTeamId) {
return;
}
await moveDocument({ documentId, teamId: selectedTeamId });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Team</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder={_(msg`Select a team`)} />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
<Trans>Loading teams...</Trans>
</SelectItem>
) : (
teams?.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
)}
<AvatarFallback className="text-sm text-gray-400">
{team.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{team.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,217 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type DocumentMoveToFolderDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveDocumentFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
export const DocumentMoveToFolderDialog = ({
documentId,
open,
onOpenChange,
currentFolderId,
...props
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
defaultValues: {
folderId: currentFolderId,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,
},
{
enabled: open,
},
);
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
useEffect(() => {
if (!open) {
form.reset();
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await moveDocumentToFolder({
documentId,
folderId: data.folderId ?? null,
});
const documentsPath = formatDocumentsPath(team.url);
if (data.folderId) {
await navigate(`${documentsPath}/f/${data.folderId}`);
} else {
await navigate(documentsPath);
}
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the document to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the document.`),
variant: 'destructive',
});
}
};
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a folder to move this document to.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
</Button>
{folders?.data.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={
isFoldersLoading || form.formState.isSubmitting || currentFolderId === null
}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -4,14 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Team } from '@prisma/client';
import { type Document, type Recipient, SigningStatus } from '@prisma/client';
import { type Recipient, SigningStatus } from '@prisma/client';
import { History } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -36,16 +36,14 @@ import {
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
import { StackAvatar } from '../general/stack-avatar';
const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Document & {
team: Pick<Team, 'id' | 'url'> | null;
};
document: TDocumentRow;
recipients: Recipient[];
};
@ -59,7 +57,7 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useSession();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();

View File

@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
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 { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type CreateFolderDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const navigate = useNavigate();
const team = useCurrentTeam();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
const form = useForm<TCreateFolderFormSchema>({
resolver: zodResolver(ZCreateFolderFormSchema),
defaultValues: {
name: '',
},
});
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.DOCUMENT,
});
setIsCreateFolderOpen(false);
const documentsPath = formatDocumentsPath(team.url);
await navigate(`${documentsPath}/f/${newFolder.id}`);
toast({
description: 'Folder created successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: 'Failed to create folder',
description: _(msg`This folder name is already taken.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to create folder',
description: _(msg`An unknown error occurred while creating the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isCreateFolderOpen) {
form.reset();
}
}, [isCreateFolderOpen, form]);
return (
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="flex items-center space-x-2">
<FolderPlusIcon className="h-4 w-4" />
<span>Create Folder</span>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogDescription>
Enter a name for your new folder. Folders help you organise your documents.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input placeholder="My Folder" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,159 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
}),
});
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
const form = useForm<TDeleteFolderFormSchema>({
resolver: zodResolver(ZDeleteFolderFormSchema),
defaultValues: {
confirmText: '',
},
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
});
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to delete does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Folder</DialogTitle>
<DialogDescription>
Are you sure you want to delete this folder?
{folder && folder._count.documents > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.documents} document(s). Deleting it will also
delete all documents in the folder.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
delete all subfolders and their contents.
</span>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={deleteMessage} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
Delete
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,169 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderMoveDialogProps = {
foldersData: TFolderWithSubfolders[] | undefined;
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveFolderFormSchema = z.object({
targetFolderId: z.string().nullable(),
});
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
export const FolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: FolderMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
defaultValues: {
targetFolderId: folder?.parentId ?? null,
},
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return;
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId || null,
});
onOpenChange(false);
toast({
title: 'Folder moved successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to move does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-2">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(null)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
Move Folder
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,173 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderSettingsDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderSettingsDialog = ({
folder,
isOpen,
onOpenChange,
}: FolderSettingsDialogProps) => {
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
name: folder?.name ?? '',
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) return;
try {
await updateFolder({
id: folder.id,
name: data.name,
visibility: isTeamContext
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
title: _(msg`Folder updated successfully`),
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Folder not found`),
});
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Folder Settings</DialogTitle>
<DialogDescription>Manage the settings for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isTeamContext && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
Managers and above
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,423 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { ExternalLinkIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
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 { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({
name: true,
});
export type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const actionSearchParam = searchParams?.get('action');
const [step, setStep] = useState<'billing' | 'create'>(
IS_BILLING_ENABLED() ? 'billing' : 'create',
);
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(ZCreateOrganisationFormSchema),
defaultValues: {
name: '',
},
});
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
const { data: plansData } = trpc.billing.plans.get.useQuery();
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
try {
const response = await createOrganisation({
name,
priceId: selectedPriceId,
});
if (response.paymentRequired) {
window.open(response.checkoutUrl, '_blank');
setOpen(false);
return;
}
await refreshSession();
setOpen(false);
toast({
title: t`Success`,
description: t`Your organisation has been created.`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (actionSearchParam === 'add-organisation') {
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open]);
useEffect(() => {
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 className="flex-shrink-0" variant="secondary">
<Trans>Create organisation</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
{match(step)
.with('billing', () => (
<>
<DialogHeader>
<DialogTitle>
<Trans>Select a plan</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a plan to continue</Trans>
</DialogDescription>
</DialogHeader>
<fieldset aria-label="Plan select">
{plansData ? (
<BillingPlanForm
value={selectedPriceId}
onChange={setSelectedPriceId}
plans={plansData.plans}
canCreateFreeOrganisation={plansData.canCreateFreeOrganisation}
/>
) : (
<SpinnerBox className="py-32" />
)}
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" onClick={() => setStep('create')}>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</fieldset>
</>
))
.with('create', () => (
<>
<DialogHeader>
<DialogTitle>
<Trans>Create organisation</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create an organisation to collaborate with teams</Trans>
</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>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
{IS_BILLING_ENABLED() ? (
<Button
type="button"
variant="secondary"
onClick={() => setStep('billing')}
>
<Trans>Back</Trans>
</Button>
) : (
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
)}
<Button
type="submit"
data-testid="dialog-create-organisation-button"
loading={form.formState.isSubmitting}
>
{selectedPriceId ? <Trans>Checkout</Trans> : <Trans>Create</Trans>}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</>
))
.exhaustive()}
</DialogContent>
</Dialog>
);
};
// This is separated from the internal claims constant because we need to use the msg
// macro which would cause import issues.
const internalClaimsDescription: {
[key in INTERNAL_CLAIM_ID]: MessageDescriptor | string;
} = {
[INTERNAL_CLAIM_ID.FREE]: msg`5 Documents a month`,
[INTERNAL_CLAIM_ID.INDIVIDUAL]: msg`Unlimited documents, API and more`,
[INTERNAL_CLAIM_ID.TEAM]: msg`Embedding, 5 members included and more`,
[INTERNAL_CLAIM_ID.PLATFORM]: msg`Whitelabeling, unlimited members and more`,
[INTERNAL_CLAIM_ID.ENTERPRISE]: '',
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: '',
};
type BillingPlanFormProps = {
value: string;
onChange: (priceId: string) => void;
plans: InternalClaimPlans;
canCreateFreeOrganisation: boolean;
};
const BillingPlanForm = ({
value,
onChange,
plans,
canCreateFreeOrganisation,
}: BillingPlanFormProps) => {
const { t } = useLingui();
const [billingPeriod, setBillingPeriod] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
const dynamicPlans = useMemo(() => {
return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.TEAM, INTERNAL_CLAIM_ID.PLATFORM].map(
(planId) => {
const plan = plans[planId];
return {
id: planId,
name: plan.name,
description: parseMessageDescriptorMacro(t, internalClaimsDescription[planId]),
monthlyPrice: plan.monthlyPrice,
yearlyPrice: plan.yearlyPrice,
};
},
);
}, [plans]);
useEffect(() => {
if (value === '' && !canCreateFreeOrganisation) {
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
}
}, [value]);
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value);
setBillingPeriod(billingPeriod);
onChange(plan?.[billingPeriod]?.id ?? Object.keys(plans)[0]);
};
return (
<div className="space-y-4">
<Tabs
className="flex w-full items-center justify-center"
defaultValue="monthlyPrice"
value={billingPeriod}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => onBillingPeriodChange(value as 'monthlyPrice' | 'yearlyPrice')}
>
<TabsList className="flex w-full justify-center">
<TabsTrigger className="w-full" value="monthlyPrice">
<Trans>Monthly</Trans>
</TabsTrigger>
<TabsTrigger className="w-full" value="yearlyPrice">
<Trans>Yearly</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-4 grid gap-4 text-sm">
<button
onClick={() => onChange('')}
className={cn(
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
{
'ring-primary/10 border-primary ring-2 ring-offset-1': '' === value,
},
)}
disabled={!canCreateFreeOrganisation}
>
<div className="w-full text-left">
<div className="flex items-center justify-between">
<p className="text-medium">
<Trans>Free</Trans>
</p>
<Badge size="small" variant="neutral" className="ml-1.5">
{canCreateFreeOrganisation ? (
<Trans>1 Free organisations left</Trans>
) : (
<Trans>0 Free organisations left</Trans>
)}
</Badge>
</div>
<div className="text-muted-foreground">
<Trans>5 documents a month</Trans>
</div>
</div>
</button>
{dynamicPlans.map((plan) => (
<button
key={plan[billingPeriod]?.id}
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
className={cn(
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
{
'ring-primary/10 border-primary ring-2 ring-offset-1':
plan[billingPeriod]?.id === value,
},
)}
>
<div className="w-full text-left">
<p className="font-medium">{plan.name}</p>
<p className="text-muted-foreground">{plan.description}</p>
</div>
<div className="whitespace-nowrap text-right text-sm font-medium">
<p>{plan[billingPeriod]?.friendlyPrice}</p>
<span className="text-muted-foreground text-xs">
{billingPeriod === 'monthlyPrice' ? (
<Trans>per month</Trans>
) : (
<Trans>per year</Trans>
)}
</span>
</div>
</button>
))}
<Link
to="https://documen.so/enterprise-cta"
target="_blank"
className="bg-muted/30 flex items-center space-x-2 rounded-md border p-4"
>
<div className="flex-1 font-normal">
<p className="text-muted-foreground font-medium">
<Trans>Enterprise</Trans>
</p>
<p className="text-muted-foreground flex flex-row items-center gap-1">
<Trans>Contact sales here</Trans>
<ExternalLinkIcon className="h-4 w-4" />
</p>
</div>
</Link>
</div>
<div className="mt-6 text-center">
<Link
to="https://documenso.com/pricing"
className="text-primary hover:text-primary/80 flex items-center justify-center gap-1 text-sm hover:underline"
target="_blank"
>
<Trans>Compare all plans and features in detail</Trans>
<ExternalLinkIcon className="h-4 w-4" />
</Link>
</div>
</div>
);
};

View File

@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
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 { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationDeleteDialogProps = {
trigger?: React.ReactNode;
};
export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const organisation = useCurrentOrganisation();
const deleteMessage = _(msg`delete ${organisation.name}`);
const ZDeleteOrganisationFormSchema = z.object({
organisationName: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
}),
});
const form = useForm({
resolver: zodResolver(ZDeleteOrganisationFormSchema),
defaultValues: {
organisationName: '',
},
});
const { mutateAsync: deleteOrganisation } = trpc.organisation.delete.useMutation();
const onFormSubmit = async () => {
try {
await deleteOrganisation({ organisationId: organisation.id });
toast({
title: _(msg`Success`),
description: _(msg`Your organisation has been successfully deleted.`),
duration: 5000,
});
await navigate('/settings/organisations');
await refreshSession();
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to delete this organisation. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure you wish to delete this organisation?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are about to delete <span className="font-semibold">{organisation.name}</span>.
All data related to this organisation such as teams, documents, and all other
resources will be deleted. This action is irreversible.
</Trans>
</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="organisationName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,251 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationGroupRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-group.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationGroupCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({
name: true,
memberIds: true,
organisationRole: true,
});
type TCreateOrganisationGroupFormSchema = z.infer<typeof ZCreateOrganisationGroupFormSchema>;
export const OrganisationGroupCreateDialog = ({
trigger,
...props
}: OrganisationGroupCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const organisation = useCurrentOrganisation();
const form = useForm({
resolver: zodResolver(ZCreateOrganisationGroupFormSchema),
defaultValues: {
name: '',
organisationRole: OrganisationMemberRole.MEMBER,
memberIds: [],
},
});
const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation();
const { data: membersFindResult, isLoading: isLoadingMembers } =
trpc.organisation.member.find.useQuery({
organisationId: organisation.id,
});
const members = membersFindResult?.data ?? [];
const onFormSubmit = async ({
name,
organisationRole,
memberIds,
}: TCreateOrganisationGroupFormSchema) => {
try {
await createOrganisationGroup({
organisationId: organisation.id,
name,
organisationRole,
memberIds,
});
setOpen(false);
toast({
title: t`Success`,
description: t`Group has been created.`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to create a group. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
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 className="flex-shrink-0" variant="secondary">
<Trans>Create group</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create group</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Organise your members into groups which can be assigned to teams</Trans>
</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>
<Trans>Group Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="organisationRole"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
organisation.currentOrganisationRole
].map((role) => (
<SelectItem key={role} value={role}>
{t(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memberIds"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={members.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={isLoadingMembers}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
/>
</FormControl>
<FormDescription>
<Trans>Select the members to add to this group</Trans>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-button"
loading={form.formState.isSubmitting}
>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,117 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
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 OrganisationGroupDeleteDialogProps = {
organisationGroupId: string;
organisationGroupName: string;
trigger?: React.ReactNode;
};
export const OrganisationGroupDeleteDialog = ({
trigger,
organisationGroupId,
organisationGroupName,
}: OrganisationGroupDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: deleteGroup, isPending: isDeleting } =
trpc.organisation.group.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this group from the organisation.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete organisation group</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following group from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{organisationGroupName}
</AlertDescription>
</Alert>
<fieldset disabled={isDeleting}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeleting}
onClick={async () =>
deleteGroup({
organisationId: organisation.id,
groupId: organisationGroupId,
})
}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,115 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { OrganisationMemberRole } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } 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 OrganisationLeaveDialogProps = {
organisationId: string;
organisationName: string;
organisationAvatarImageId?: string | null;
role: OrganisationMemberRole;
trigger?: React.ReactNode;
};
export const OrganisationLeaveDialog = ({
trigger,
organisationId,
organisationName,
organisationAvatarImageId,
role,
}: OrganisationLeaveDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } =
trpc.organisation.leave.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully left this organisation.`,
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLeavingOrganisation && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
<Trans>Leave organisation</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>You are about to leave the following organisation.</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(organisationAvatarImageId)}
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
primaryText={organisationName}
secondaryText={t(ORGANISATION_MEMBER_ROLE_MAP[role])}
/>
</Alert>
<fieldset disabled={isLeavingOrganisation}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isLeavingOrganisation}
onClick={async () => leaveOrganisation({ organisationId })}
>
<Trans>Leave</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,123 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } 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 OrganisationMemberDeleteDialogProps = {
organisationMemberId: string;
organisationMemberName: string;
organisationMemberEmail: string;
trigger?: React.ReactNode;
};
export const OrganisationMemberDeleteDialog = ({
trigger,
organisationMemberId,
organisationMemberName,
organisationMemberEmail,
}: OrganisationMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: deleteOrganisationMembers, isPending: isDeletingOrganisationMember } =
trpc.organisation.member.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this user from the organisation.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this user. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeletingOrganisationMember && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete organisation member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following user from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
secondaryText={organisationMemberEmail}
/>
</Alert>
<fieldset disabled={isDeletingOrganisationMember}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingOrganisationMember}
onClick={async () =>
deleteOrganisationMembers({
organisationId: organisation.id,
organisationMemberId,
})
}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,478 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
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 { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationMemberInviteDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZInviteOrganisationMembersFormSchema = z
.object({
invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations,
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, invitation] of items.invitations.entries()) {
const email = invitation.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', firstFoundIndex, 'email'],
});
}
});
type TInviteOrganisationMembersFormSchema = z.infer<typeof ZInviteOrganisationMembersFormSchema>;
type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportOrganisationMemberSchema = z.array(
z.object({
email: z.string().email(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
}),
);
export const OrganisationMemberInviteDialog = ({
trigger,
...props
}: OrganisationMemberInviteDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
const { _ } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const form = useForm<TInviteOrganisationMembersFormSchema>({
resolver: zodResolver(ZInviteOrganisationMembersFormSchema),
defaultValues: {
invitations: [
{
email: '',
organisationRole: OrganisationMemberRole.MEMBER,
},
],
},
});
const {
append: appendOrganisationMemberInvite,
fields: organisationMemberInvites,
remove: removeOrganisationMemberInvite,
} = useFieldArray({
control: form.control,
name: 'invitations',
});
const { mutateAsync: createOrganisationMemberInvites } =
trpc.organisation.member.invite.createMany.useMutation();
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.id,
});
const onAddOrganisationMemberInvite = () => {
appendOrganisationMemberInvite({
email: '',
organisationRole: OrganisationMemberRole.MEMBER,
});
};
const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => {
try {
await createOrganisationMemberInvites({
organisationId: organisation.id,
invitations,
});
toast({
title: _(msg`Success`),
description: _(msg`Organisation invitations have been sent.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to invite organisation members. Please try again later.`,
),
variant: 'destructive',
});
}
};
const dialogState = useMemo(() => {
if (!fullOrganisation) {
return 'loading';
}
if (!IS_BILLING_ENABLED()) {
return 'form';
}
if (fullOrganisation.organisationClaim.memberCount === 0) {
return 'form';
}
// This is probably going to screw us over in the future.
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
return 'alert';
}
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (!open) {
form.reset();
setInvitationType('INDIVIDUAL');
}
}, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const csvFile = e.target.files[0];
Papa.parse(csvFile, {
skipEmptyLines: true,
comments: 'Work email,Job title',
complete: (results: ParseResult<string[]>) => {
const members = results.data.map((row) => {
const [email, role] = row;
return {
email: email.trim(),
organisationRole: role.trim().toUpperCase(),
};
});
// Remove the first row if it contains the headers.
if (members.length > 1 && members[0].organisationRole.toUpperCase() === 'ROLE') {
members.shift();
}
try {
const importedInvitations = ZImportOrganisationMemberSchema.parse(members);
form.setValue('invitations', importedInvitations);
form.clearErrors('invitations');
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Please check the CSV file and make sure it is according to our format`,
),
variant: 'destructive',
});
}
},
});
};
const downloadTemplate = () => {
const data = [
{ email: 'admin@documenso.com', role: 'Admin' },
{ email: 'manager@documenso.com', role: 'Manager' },
{ email: 'member@documenso.com', role: 'Member' },
];
const csvContent =
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const blob = new Blob([csvContent], {
type: 'text/csv',
});
downloadFile({
filename: 'documenso-organisation-member-invites-template.csv',
data: blob,
});
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Invite member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Invite organisation members</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>An email containing an invitation will be sent to each member.</Trans>
</DialogDescription>
</DialogHeader>
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
{dialogState === 'alert' && (
<>
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<AlertDescription>
<Trans>
Your plan does not support inviting members. Please upgrade or your plan or
contact sales at <a href="mailto:support@documenso.com">support@documenso.com</a>{' '}
if you would like to discuss your options.
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
</DialogFooter>
</>
)}
{dialogState === 'form' && (
<Tabs
defaultValue="INDIVIDUAL"
value={invitationType}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<MailIcon size={20} className="mr-2" />
<Trans>Invite Members</Trans>
</TabsTrigger>
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="INDIVIDUAL">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{organisationMemberInvites.map((organisationMemberInvite, index) => (
<div
className="flex w-full flex-row space-x-4"
key={organisationMemberInvite.id}
>
<FormField
control={form.control}
name={`invitations.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Email address</Trans>
</FormLabel>
)}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`invitations.${index}.organisationRole`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Organisation Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
organisation.currentOrganisationRole
].map((role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_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={organisationMemberInvites.length === 1}
onClick={() => removeOrganisationMemberInvite(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => onAddOrganisationMemberInvite()}
>
<PlusCircle className="mr-2 h-4 w-4" />
<Trans>Add more</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
<Trans>Invite</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</TabsContent>
<TabsContent value="BULK">
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
<p className="mt-1 text-sm">
<Trans>Click here to upload</Trans>
</p>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
/>
</CardContent>
</Card>
<DialogFooter>
<Button type="button" variant="secondary" onClick={downloadTemplate}>
<Download className="mr-2 h-4 w-4" />
<Trans>Template</Trans>
</Button>
</DialogFooter>
</div>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationMemberUpdateDialogProps = {
currentUserOrganisationRole: OrganisationMemberRole;
trigger?: React.ReactNode;
organisationId: string;
organisationMemberId: string;
organisationMemberName: string;
organisationMemberRole: OrganisationMemberRole;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateOrganisationMemberFormSchema = z.object({
role: z.nativeEnum(OrganisationMemberRole),
});
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
export const OrganisationMemberUpdateDialog = ({
currentUserOrganisationRole,
trigger,
organisationId,
organisationMemberId,
organisationMemberName,
organisationMemberRole,
...props
}: OrganisationMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm<ZUpdateOrganisationMemberSchema>({
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
defaultValues: {
role: organisationMemberRole,
},
});
const { mutateAsync: updateOrganisationMember } = trpc.organisation.member.update.useMutation();
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
try {
await updateOrganisationMember({
organisationId,
organisationMemberId,
data: {
role,
},
});
toast({
title: _(msg`Success`),
description: _(msg`You have updated ${organisationMemberName}.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset();
if (
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
) {
setOpen(false);
toast({
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
variant: 'destructive',
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Update organisation member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update organisation member</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-full" position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole].map(
(role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -49,7 +49,7 @@ import {
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type ManagePublicTemplateDialogProps = {
directTemplates: (Template & {
@ -95,7 +95,7 @@ export const ManagePublicTemplateDialog = ({
const [open, onOpenChange] = useState(isOpen);
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);

View File

@ -1,188 +0,0 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader, TagIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamCheckoutCreateDialogProps = {
pendingTeamId: number | null;
onClose: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const MotionCard = motion(Card);
export const TeamCheckoutCreateDialog = ({
pendingTeamId,
onClose,
...props
}: TeamCheckoutCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
const { data, isLoading } = trpc.team.getTeamPrices.useQuery();
const { mutateAsync: createCheckout, isPending: isCreatingCheckout } =
trpc.team.createTeamPendingCheckout.useMutation({
onSuccess: (checkoutUrl) => {
window.open(checkoutUrl, '_blank');
onClose();
},
onError: () =>
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to create a checkout session. Please try again, or contact support`,
),
variant: 'destructive',
}),
});
const selectedPrice = useMemo(() => {
if (!data) {
return null;
}
return data[interval];
}, [data, interval]);
const handleOnOpenChange = (open: boolean) => {
if (pendingTeamId === null) {
return;
}
if (!open) {
onClose();
}
};
if (pendingTeamId === null) {
return null;
}
return (
<Dialog {...props} open={pendingTeamId !== null} onOpenChange={handleOnOpenChange}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Team checkout</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Payment is required to finalise the creation of your team.</Trans>
</DialogDescription>
</DialogHeader>
{(isLoading || !data) && (
<div className="flex h-20 items-center justify-center text-sm">
{isLoading ? (
<Loader className="text-documenso h-6 w-6 animate-spin" />
) : (
<p>
<Trans>Something went wrong</Trans>
</p>
)}
</div>
)}
{data && selectedPrice && !isLoading && (
<div>
<Tabs
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => setInterval(value as 'monthly' | 'yearly')}
value={interval}
className="mb-4"
>
<TabsList className="w-full">
{[data.monthly, data.yearly].map((price) => (
<TabsTrigger key={price.priceId} className="w-full" value={price.interval}>
{price.friendlyInterval}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<AnimatePresence mode="wait">
<MotionCard
key={selectedPrice.priceId}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
>
<CardContent className="flex h-full flex-col p-6">
{selectedPrice.interval === 'monthly' ? (
<div className="text-muted-foreground text-lg font-medium">
$50 USD <span className="text-xs">per month</span>
</div>
) : (
<div className="text-muted-foreground flex items-center justify-between text-lg font-medium">
<span>
$480 USD <span className="text-xs">per year</span>
</span>
<div className="bg-primary text-primary-foreground ml-2 inline-flex flex-row items-center justify-center rounded px-2 py-1 text-xs">
<TagIcon className="mr-1 h-4 w-4" />
20% off
</div>
</div>
)}
<div className="text-muted-foreground mt-1.5 text-sm">
<p>
<Trans>This price includes minimum 5 seats.</Trans>
</p>
<p className="mt-1">
<Trans>Adding and removing seats will adjust your invoice accordingly.</Trans>
</p>
</div>
</CardContent>
</MotionCard>
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
disabled={isCreatingCheckout}
onClick={() => onClose()}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
loading={isCreatingCheckout}
onClick={async () =>
createCheckout({
interval: selectedPrice.interval,
pendingTeamId,
})
}
>
<Trans>Checkout</Trans>
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@ -7,15 +7,18 @@ import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_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 { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
@ -34,29 +37,37 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamCreateDialogProps = {
trigger?: React.ReactNode;
onCreated?: () => Promise<void>;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({
teamName: true,
teamUrl: true,
inheritMembers: true,
});
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => {
export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const organisation = useCurrentOrganisation();
const [open, setOpen] = useState(false);
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.id,
});
const actionSearchParam = searchParams?.get('action');
const form = useForm({
@ -64,24 +75,25 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
defaultValues: {
teamName: '',
teamUrl: '',
inheritMembers: true,
},
});
const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => {
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
try {
const response = await createTeam({
await createTeam({
organisationId: organisation.id,
teamName,
teamUrl,
inheritMembers,
});
setOpen(false);
if (response.paymentRequired) {
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
return;
}
await onCreated?.();
await refreshSession();
toast({
title: _(msg`Success`),
@ -114,6 +126,26 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
return text.toLowerCase().replace(/\s+/g, '-');
};
const dialogState = useMemo(() => {
if (!fullOrganisation) {
return 'loading';
}
if (!IS_BILLING_ENABLED()) {
return 'form';
}
if (fullOrganisation.organisationClaim.teamCount === 0) {
return 'form';
}
if (fullOrganisation.organisationClaim.teamCount <= fullOrganisation.teams.length) {
return 'alert';
}
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (actionSearchParam === 'add-team') {
setOpen(true);
@ -145,89 +177,141 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
<Trans>Create team</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<DialogDescription>
<Trans>Create a team to collaborate with your team members.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
{dialogState === 'alert' && (
<>
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Team Name</Trans>
</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onChange={(event) => {
const oldGeneratedUrl = mapTextToUrl(field.value);
const newGeneratedUrl = mapTextToUrl(event.target.value);
<AlertDescription className="mt-0">
<Trans>
You have reached the maximum number of teams for your plan. Please contact sales
at <a href="mailto:support@documenso.com">support@documenso.com</a> if you would
like to adjust your plan.
</Trans>
</AlertDescription>
</Alert>
const urlField = form.getValues('teamUrl');
if (urlField === oldGeneratedUrl) {
form.setValue('teamUrl', newGeneratedUrl);
}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
</DialogFooter>
</>
)}
field.onChange(event);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{dialogState === 'form' && (
<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 required>
<Trans>Team Name</Trans>
</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onChange={(event) => {
const oldGeneratedUrl = mapTextToUrl(field.value);
const newGeneratedUrl = mapTextToUrl(event.target.value);
<FormField
control={form.control}
name="teamUrl"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Team URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
<Trans>A unique URL to identify your team</Trans>
)}
</span>
)}
const urlField = form.getValues('teamUrl');
if (urlField === oldGeneratedUrl) {
form.setValue('teamUrl', newGeneratedUrl);
}
<FormMessage />
</FormItem>
)}
/>
field.onChange(event);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<FormField
control={form.control}
name="teamUrl"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Team URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
<Trans>A unique URL to identify your team</Trans>
)}
</span>
)}
<Button
type="submit"
data-testid="dialog-create-team-button"
loading={form.formState.isSubmitting}
>
<Trans>Create Team</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="inheritMembers"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id="inherit-members"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="inherit-members"
>
<Trans>Allow all organisation members to access this team</Trans>
</label>
</div>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-team-button"
loading={form.formState.isSubmitting}
>
<Trans>Create Team</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);

View File

@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -35,15 +36,22 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamDeleteDialogProps = {
teamId: number;
teamName: string;
redirectTo?: string;
trigger?: React.ReactNode;
};
export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => {
export const TeamDeleteDialog = ({
trigger,
teamId,
teamName,
redirectTo,
}: TeamDeleteDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const deleteMessage = _(msg`delete ${teamName}`);
@ -60,19 +68,23 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
},
});
const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
const onFormSubmit = async () => {
try {
await deleteTeam({ teamId });
await refreshSession();
toast({
title: _(msg`Success`),
description: _(msg`Your team has been successfully deleted.`),
duration: 5000,
});
await navigate('/settings/teams');
if (redirectTo) {
await navigate(redirectTo);
}
setOpen(false);
} catch (err) {
@ -113,7 +125,7 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
<Trans>Delete team</Trans>
<Trans>Delete</Trans>
</Button>
)}
</DialogTrigger>

View File

@ -61,12 +61,12 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
},
});
const { mutateAsync: createTeamEmailVerification, isPending } =
trpc.team.createTeamEmailVerification.useMutation();
const { mutateAsync: sendTeamEmailVerification, isPending } =
trpc.team.email.verification.send.useMutation();
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
try {
await createTeamEmailVerification({
await sendTeamEmailVerification({
teamId,
name,
email,

View File

@ -48,7 +48,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
const { revalidate } = useRevalidator();
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
trpc.team.email.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@ -67,7 +67,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
});
const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } =
trpc.team.deleteTeamEmailVerification.useMutation({
trpc.team.email.verification.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -61,7 +61,7 @@ export const TeamEmailUpdateDialog = ({
},
});
const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
try {

View File

@ -0,0 +1,305 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
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,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamGroupCreateDialogProps = Omit<DialogPrimitive.DialogProps, 'children'>;
const ZAddTeamMembersFormSchema = z.object({
groups: z.array(
z.object({
organisationGroupId: z.string(),
teamRole: z.nativeEnum(TeamMemberRole),
}),
),
});
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT');
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
defaultValues: {
groups: [],
},
});
const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation();
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
organisationId: team.organisationId,
perPage: 100, // Won't really work if they somehow have more than 100 groups.
types: [OrganisationGroupType.CUSTOM],
});
const teamGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
perPage: 100, // Won't really work if they somehow have more than 100 groups.
});
const avaliableOrganisationGroups = useMemo(() => {
const organisationGroups = organisationGroupQuery.data?.data ?? [];
const teamGroups = teamGroupQuery.data?.data ?? [];
return organisationGroups.filter(
(group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id),
);
}, [organisationGroupQuery, teamGroupQuery]);
const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => {
try {
await createTeamGroups({
teamId: team.id,
groups,
});
toast({
title: t`Success`,
description: t`Team members have been added.`,
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
setStep('SELECT');
}
}, [open, form]);
return (
<Dialog
{...props}
open={open}
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process.
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add groups</Trans>
</Button>
</DialogTrigger>
<DialogContent hideClose={true} position="center">
{match(step)
.with('SELECT', () => (
<DialogHeader>
<DialogTitle>
<Trans>Add members</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select members or groups of members to add to the team.</Trans>
</DialogDescription>
</DialogHeader>
))
.with('ROLES', () => (
<DialogHeader>
<DialogTitle>
<Trans>Add group roles</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure the team roles for each group</Trans>
</DialogDescription>
</DialogHeader>
))
.exhaustive()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && (
<>
<FormField
control={form.control}
name="groups"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Groups</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationGroups.map((group) => ({
label: group.name ?? group.organisationRole,
value: group.id,
}))}
loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading}
selectedValues={field.value.map(
({ organisationGroupId }) => organisationGroupId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationGroupId) => ({
organisationGroupId,
teamRole:
field.value.find(
(value) => value.organisationGroupId === organisationGroupId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select groups`}
/>
</FormControl>
<FormDescription>
<Trans>Select groups to add to this team</Trans>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={form.getValues('groups').length === 0}
onClick={() => {
setStep('ROLES');
}}
>
<Trans>Next</Trans>
</Button>
</DialogFooter>
</>
)}
{step === 'ROLES' && (
<>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{form.getValues('groups').map((group, index) => (
<div className="flex w-full flex-row space-x-4" key={index}>
<div className="w-full space-y-2">
{index === 0 && (
<FormLabel>
<Trans>Group</Trans>
</FormLabel>
)}
<Input
readOnly
className="bg-background"
value={
avaliableOrganisationGroups.find(
({ id }) => id === group.organisationGroupId,
)?.name || t`Untitled Group`
}
/>
</div>
<FormField
control={form.control}
name={`groups.${index}.teamRole`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Team Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
(role) => (
<SelectItem key={role} value={role}>
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
</div>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
<Trans>Back</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create Groups</Trans>
</Button>
</DialogFooter>
</>
)}
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,139 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
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';
import { useCurrentTeam } from '~/providers/team';
export type TeamGroupDeleteDialogProps = {
trigger?: React.ReactNode;
teamGroupId: string;
teamGroupName: string;
teamGroupRole: TeamMemberRole;
};
export const TeamGroupDeleteDialog = ({
trigger,
teamGroupId,
teamGroupName,
teamGroupRole,
}: TeamGroupDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.team.group.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this group from the team.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete team group</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following group from{' '}
<span className="font-semibold">{team.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
<>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{teamGroupName}
</AlertDescription>
</Alert>
<fieldset disabled={isDeleting}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeleting}
onClick={async () =>
deleteGroup({
teamId: team.id,
teamGroupId: teamGroupId,
})
}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</>
) : (
<>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
<Trans>You cannot delete a group which has a higher role than you.</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamGroupUpdateDialogProps = {
trigger?: React.ReactNode;
teamGroupId: string;
teamGroupName: string;
teamGroupRole: TeamMemberRole;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamGroupFormSchema = z.object({
role: z.nativeEnum(TeamMemberRole),
});
type ZUpdateTeamGroupSchema = z.infer<typeof ZUpdateTeamGroupFormSchema>;
export const TeamGroupUpdateDialog = ({
trigger,
teamGroupId,
teamGroupName,
teamGroupRole,
...props
}: TeamGroupUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<ZUpdateTeamGroupSchema>({
resolver: zodResolver(ZUpdateTeamGroupFormSchema),
defaultValues: {
role: teamGroupRole,
},
});
const { mutateAsync: updateTeamGroup } = trpc.team.group.update.useMutation();
const onFormSubmit = async ({ role }: ZUpdateTeamGroupSchema) => {
try {
await updateTeamGroup({
id: teamGroupId,
data: {
teamRole: role,
},
});
toast({
title: _(msg`Success`),
description: _(msg`You have updated the team group.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to update this team member. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Update team group</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update team group</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are currently updating the <span className="font-bold">{teamGroupName}</span> team
group.
</Trans>
</DialogDescription>
</DialogHeader>
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-full" position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
) : (
<>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
<Trans>You cannot modify a group which has a higher role than you.</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,93 @@
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGroup } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type TeamMemberInheritDisableDialogProps = {
group: TeamGroup;
};
export const TeamMemberInheritDisableDialog = ({ group }: TeamMemberInheritDisableDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const team = useCurrentTeam();
const deleteGroupMutation = trpc.team.group.delete.useMutation({
onSuccess: () => {
toast({
title: t`Access disabled`,
duration: 5000,
});
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`We encountered an unknown error while attempting to disable access.`,
variant: 'destructive',
duration: 5000,
});
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Disable access</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove default access to this team for all organisation members. Any
members not explicitly added to this team will no longer have access.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
loading={deleteGroupMutation.isPending}
onClick={() =>
deleteGroupMutation.mutate({
teamId: team.id,
teamGroupId: group.id,
})
}
>
<Trans>Disable</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,109 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export const TeamMemberInheritEnableDialog = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { toast } = useToast();
const { t } = useLingui();
const { mutateAsync: createTeamGroups, isPending } = trpc.team.group.createMany.useMutation({
onSuccess: () => {
toast({
title: t`Access enabled`,
duration: 5000,
});
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`We encountered an unknown error while attempting to enable access.`,
variant: 'destructive',
duration: 5000,
});
},
});
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
organisationId: organisation.id,
perPage: 1,
types: [OrganisationGroupType.INTERNAL_ORGANISATION],
organisationRoles: [OrganisationMemberRole.MEMBER],
});
const enableAccessGroup = async () => {
if (!organisationGroupQuery.data?.data[0]?.id) {
return;
}
await createTeamGroups({
teamId: team.id,
groups: [
{
organisationGroupId: organisationGroupQuery.data?.data[0]?.id,
teamRole: TeamMemberRole.MEMBER,
},
],
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Enable access</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to give all organisation members access to this team under their
organisation role.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="submit"
disabled={organisationGroupQuery.isPending}
loading={isPending}
onClick={enableAccessGroup}
>
<Trans>Enable</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,117 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } 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 TeamLeaveDialogProps = {
teamId: number;
teamName: string;
teamAvatarImageId?: string | null;
role: TeamMemberRole;
trigger?: React.ReactNode;
};
export const TeamLeaveDialog = ({
trigger,
teamId,
teamName,
teamAvatarImageId,
role,
}: TeamLeaveDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: leaveTeam, isPending: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully left this team.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to leave this team. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLeavingTeam && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
<Trans>Leave team</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>You are about to leave the following team.</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(teamAvatarImageId)}
avatarFallback={teamName.slice(0, 1).toUpperCase()}
primaryText={teamName}
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}
/>
</Alert>
<fieldset disabled={isLeavingTeam}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isLeavingTeam}
onClick={async () => leaveTeam({ teamId })}
>
<Trans>Leave</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,305 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
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,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZAddTeamMembersFormSchema = z.object({
members: z.array(
z.object({
organisationMemberId: z.string(),
teamRole: z.nativeEnum(TeamMemberRole),
}),
),
});
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
defaultValues: {
members: [],
},
});
const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation();
const organisationMemberQuery = trpc.organisation.member.find.useQuery({
organisationId: team.organisationId,
});
const teamMemberQuery = trpc.team.member.find.useQuery({
teamId: team.id,
});
const avaliableOrganisationMembers = useMemo(() => {
const organisationMembers = organisationMemberQuery.data?.data ?? [];
const teamMembers = teamMemberQuery.data?.data ?? [];
return organisationMembers.filter(
(member) => !teamMembers.some((teamMember) => teamMember.id === member.id),
);
}, [organisationMemberQuery, teamMemberQuery]);
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
try {
await createTeamMembers({
teamId: team.id,
organisationMembers: members,
});
toast({
title: t`Success`,
description: t`Team members have been added.`,
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
setStep('SELECT');
}
}, [open, form]);
return (
<Dialog
{...props}
open={open}
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process.
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
</DialogTrigger>
<DialogContent hideClose={true} position="center">
{match(step)
.with('SELECT', () => (
<DialogHeader>
<DialogTitle>
<Trans>Add members</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select members or groups of members to add to the team.</Trans>
</DialogDescription>
</DialogHeader>
))
.with('MEMBERS', () => (
<DialogHeader>
<DialogTitle>
<Trans>Add members roles</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure the team roles for each member</Trans>
</DialogDescription>
</DialogHeader>
))
.exhaustive()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && (
<>
<FormField
control={form.control}
name="members"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
/>
</FormControl>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={form.getValues('members').length === 0}
onClick={() => {
setStep('MEMBERS');
}}
>
<Trans>Next</Trans>
</Button>
</DialogFooter>
</>
)}
{step === 'MEMBERS' && (
<>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{form.getValues('members').map((member, index) => (
<div className="flex w-full flex-row space-x-4" key={index}>
<div className="w-full space-y-2">
{index === 0 && (
<FormLabel>
<Trans>Member</Trans>
</FormLabel>
)}
<Input
readOnly
className="bg-background"
value={
organisationMemberQuery.data?.data.find(
({ id }) => id === member.organisationMemberId,
)?.name || ''
}
/>
</div>
<FormField
control={form.control}
name={`members.${index}.teamRole`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Team Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
(role) => (
<SelectItem key={role} value={role}>
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
</div>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
<Trans>Back</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Add Members</Trans>
</Button>
</DialogFooter>
</>
)}
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -22,9 +22,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamMemberDeleteDialogProps = {
teamId: number;
teamName: string;
teamMemberId: number;
teamMemberName: string;
teamMemberEmail: string;
memberId: string;
memberName: string;
memberEmail: string;
isInheritMemberEnabled: boolean | null;
trigger?: React.ReactNode;
};
@ -32,17 +33,18 @@ export const TeamMemberDeleteDialog = ({
trigger,
teamId,
teamName,
teamMemberId,
teamMemberName,
teamMemberEmail,
memberId,
memberName,
memberEmail,
isInheritMemberEnabled,
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } =
trpc.team.deleteTeamMembers.useMutation({
const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } =
trpc.team.member.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@ -69,7 +71,7 @@ export const TeamMemberDeleteDialog = ({
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete team member</Trans>
<Trans>Remove team member</Trans>
</Button>
)}
</DialogTrigger>
@ -88,29 +90,42 @@ export const TeamMemberDeleteDialog = ({
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={teamMemberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{teamMemberName}</span>}
secondaryText={teamMemberEmail}
/>
</Alert>
{isInheritMemberEnabled ? (
<Alert variant="neutral">
<AlertDescription>
<Trans>
You cannot remove members from this team if the inherit member feature is enabled.
</Trans>
</AlertDescription>
</Alert>
) : (
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={memberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{memberName}</span>}
secondaryText={memberEmail}
/>
</Alert>
)}
<fieldset disabled={isDeletingTeamMember}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
<Trans>Close</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
>
<Trans>Delete</Trans>
</Button>
{!isInheritMemberEnabled && (
<Button
type="submit"
variant="destructive"
disabled={Boolean(isInheritMemberEnabled)}
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
<Trans>Remove</Trans>
</Button>
)}
</DialogFooter>
</fieldset>
</DialogContent>

View File

@ -1,415 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
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 { Card, CardContent } from '@documenso/ui/primitives/card';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberInviteDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZInviteTeamMembersFormSchema = z
.object({
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, invitation] of items.invitations.entries()) {
const email = invitation.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', firstFoundIndex, 'email'],
});
}
});
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportTeamMemberSchema = z.array(
z.object({
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
}),
);
export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
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: team.id,
invitations,
});
toast({
title: _(msg`Success`),
description: _(msg`Team invitations have been sent.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to invite team members. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
setInvitationType('INDIVIDUAL');
}
}, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const csvFile = e.target.files[0];
Papa.parse(csvFile, {
skipEmptyLines: true,
comments: 'Work email,Job title',
complete: (results: ParseResult<string[]>) => {
const members = results.data.map((row) => {
const [email, role] = row;
return {
email: email.trim(),
role: role.trim().toUpperCase(),
};
});
// Remove the first row if it contains the headers.
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
members.shift();
}
try {
const importedInvitations = ZImportTeamMemberSchema.parse(members);
form.setValue('invitations', importedInvitations);
form.clearErrors('invitations');
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Please check the CSV file and make sure it is according to our format`,
),
variant: 'destructive',
});
}
},
});
};
const downloadTemplate = () => {
const data = [
{ email: 'admin@documenso.com', role: 'Admin' },
{ email: 'manager@documenso.com', role: 'Manager' },
{ email: 'member@documenso.com', role: 'Member' },
];
const csvContent =
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const blob = new Blob([csvContent], {
type: 'text/csv',
});
downloadFile({
filename: 'documenso-team-member-invites-template.csv',
data: blob,
});
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Invite member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Invite team members</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>An email containing an invitation will be sent to each member.</Trans>
</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="INDIVIDUAL"
value={invitationType}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<MailIcon size={20} className="mr-2" />
<Trans>Invite Members</Trans>
</TabsTrigger>
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="INDIVIDUAL">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{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>
<Trans>Email address</Trans>
</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>
<Trans>Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].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>
))}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => onAddTeamMemberInvite()}
>
<PlusCircle className="mr-2 h-4 w-4" />
<Trans>Add more</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
<Trans>Invite</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</TabsContent>
<TabsContent value="BULK">
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
<p className="mt-1 text-sm">
<Trans>Click here to upload</Trans>
</p>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
/>
</CardContent>
</Card>
<DialogFooter>
<Button type="button" variant="secondary" onClick={downloadTemplate}>
<Download className="mr-2 h-4 w-4" />
<Trans>Template</Trans>
</Button>
</DialogFooter>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@ -9,7 +9,8 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -43,9 +44,9 @@ export type TeamMemberUpdateDialogProps = {
currentUserTeamRole: TeamMemberRole;
trigger?: React.ReactNode;
teamId: number;
teamMemberId: number;
teamMemberName: string;
teamMemberRole: TeamMemberRole;
memberId: string;
memberName: string;
memberTeamRole: TeamMemberRole;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamMemberFormSchema = z.object({
@ -58,9 +59,9 @@ export const TeamMemberUpdateDialog = ({
currentUserTeamRole,
trigger,
teamId,
teamMemberId,
teamMemberName,
teamMemberRole,
memberId,
memberName,
memberTeamRole,
...props
}: TeamMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
@ -71,17 +72,17 @@ export const TeamMemberUpdateDialog = ({
const form = useForm<ZUpdateTeamMemberSchema>({
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
defaultValues: {
role: teamMemberRole,
role: memberTeamRole,
},
});
const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
const { mutateAsync: updateTeamMember } = trpc.team.member.update.useMutation();
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
try {
await updateTeamMember({
teamId,
teamMemberId,
memberId,
data: {
role,
},
@ -89,7 +90,7 @@ export const TeamMemberUpdateDialog = ({
toast({
title: _(msg`Success`),
description: _(msg`You have updated ${teamMemberName}.`),
description: _(msg`You have updated ${memberName}.`),
duration: 5000,
});
@ -112,7 +113,7 @@ export const TeamMemberUpdateDialog = ({
form.reset();
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
setOpen(false);
toast({
@ -121,7 +122,7 @@ export const TeamMemberUpdateDialog = ({
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentUserTeamRole, teamMemberRole, form, toast]);
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
return (
<Dialog
@ -143,9 +144,9 @@ export const TeamMemberUpdateDialog = ({
<Trans>Update team member</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<DialogDescription>
<Trans>
You are currently updating <span className="font-bold">{teamMemberName}.</span>
You are currently updating <span className="font-bold">{memberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
@ -170,7 +171,7 @@ export const TeamMemberUpdateDialog = ({
<SelectContent className="w-full" position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
{_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>

View File

@ -1,272 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
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 type TeamTransferDialogProps = {
teamId: number;
teamName: string;
ownerUserId: number;
trigger?: React.ReactNode;
};
export const TeamTransferDialog = ({
trigger,
teamId,
teamName,
ownerUserId,
}: TeamTransferDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutateAsync: requestTeamOwnershipTransfer } =
trpc.team.requestTeamOwnershipTransfer.useMutation();
const {
data,
refetch: refetchTeamMembers,
isPending: loadingTeamMembers,
isLoadingError: loadingTeamMembersError,
} = trpc.team.getTeamMembers.useQuery({
teamId,
});
const confirmTransferMessage = _(msg`transfer ${teamName}`);
const ZTransferTeamFormSchema = z.object({
teamName: z.literal(confirmTransferMessage, {
errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
}),
newOwnerUserId: z.string(),
clearPaymentMethods: z.boolean(),
});
const form = useForm<z.infer<typeof ZTransferTeamFormSchema>>({
resolver: zodResolver(ZTransferTeamFormSchema),
defaultValues: {
teamName: '',
clearPaymentMethods: false,
},
});
const onFormSubmit = async ({
newOwnerUserId,
clearPaymentMethods,
}: z.infer<typeof ZTransferTeamFormSchema>) => {
try {
await requestTeamOwnershipTransfer({
teamId,
newOwnerUserId: Number.parseInt(newOwnerUserId),
clearPaymentMethods,
});
await revalidate();
toast({
title: _(msg`Success`),
description: _(msg`An email requesting the transfer of this team has been sent.`),
duration: 5000,
});
setOpen(false);
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to request a transfer of this team. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
useEffect(() => {
if (open && loadingTeamMembersError) {
void refetchTeamMembers();
}
}, [open, loadingTeamMembersError, refetchTeamMembers]);
const teamMembers = data
? data.filter((teamMember) => teamMember.userId !== ownerUserId)
: undefined;
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="bg-background">
<Trans>Transfer team</Trans>
</Button>
)}
</DialogTrigger>
{teamMembers && teamMembers.length > 0 ? (
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Transfer team</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Transfer ownership of this team to a selected team member.</Trans>
</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="newOwnerUserId"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>New team owner</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{teamMembers.map((teamMember) => (
<SelectItem
key={teamMember.userId}
value={teamMember.userId.toString()}
>
{teamMember.user.name} ({teamMember.user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing{' '}
<span className="text-destructive">{confirmTransferMessage}</span>
</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="neutral">
<AlertDescription>
<ul className="list-outside list-disc space-y-2 pl-4">
{IS_BILLING_ENABLED() && (
<li>
<Trans>
Any payment methods attached to this team will remain attached to this
team. Please contact us if you need to update this information.
</Trans>
</li>
)}
<li>
<Trans>
The selected team member will receive an email which they must accept
before the team is transferred
</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
<Trans>Request transfer</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
) : (
<DialogContent
position="center"
className="text-muted-foreground flex items-center justify-center py-16 text-sm"
>
{loadingTeamMembers ? (
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
) : (
<p className="text-center text-sm">
{loadingTeamMembersError ? (
<Trans>An error occurred while loading team members. Please try again later.</Trans>
) : (
<Trans>You must have at least one other team member to transfer ownership.</Trans>
)}
</p>
)}
</DialogContent>
)}
</Dialog>
);
};

View File

@ -21,7 +21,7 @@ import {
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
const ZBulkSendFormSchema = z.object({
file: z.instanceof(File),
@ -46,7 +46,7 @@ export const TemplateBulkSendDialog = ({
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</Button>

View File

@ -24,11 +24,11 @@ import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateCreateDialogProps = {
teamId?: number;
templateRootPath: string;
folderId?: string;
};
export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => {
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { user } = useSession();
@ -53,6 +53,7 @@ export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogP
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId: response.id,
folderId: folderId,
});
toast({

View File

@ -15,6 +15,7 @@ import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
@ -75,6 +76,8 @@ export const TemplateDirectLinkDialog = ({
token ? 'MANAGE' : 'ONBOARD',
);
const organisation = useCurrentOrganisation();
const validDirectTemplateRecipients = useMemo(
() =>
template.recipients.filter(
@ -237,7 +240,7 @@ export const TemplateDirectLinkDialog = ({
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
to="/settings/billing"
to={`/o/${organisation.url}/settings/billing`}
>
Upgrade your account to continue!
</Link>

View File

@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
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 { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type TemplateFolderCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TemplateFolderCreateDialog = ({
trigger,
...props
}: TemplateFolderCreateDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { folderId } = useParams();
const navigate = useNavigate();
const team = useCurrentTeam();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
const form = useForm<TCreateFolderFormSchema>({
resolver: zodResolver(ZCreateFolderFormSchema),
defaultValues: {
name: '',
},
});
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.TEMPLATE,
});
setIsCreateFolderOpen(false);
toast({
description: _(msg`Folder created successfully`),
});
const templatesPath = formatTemplatesPath(team.url);
void navigate(`${templatesPath}/f/${newFolder.id}`);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: _(msg`Failed to create folder`),
description: _(msg`This folder name is already taken.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Failed to create folder`),
description: _(msg`An unknown error occurred while creating the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isCreateFolderOpen) {
form.reset();
}
}, [isCreateFolderOpen, form]);
return (
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="flex items-center space-x-2">
<FolderPlusIcon className="h-4 w-4" />
<span>Create Folder</span>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogDescription>
Enter a name for your new folder. Folders help you organise your templates.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input placeholder="My Folder" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,163 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TemplateFolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TemplateFolderDeleteDialog = ({
folder,
isOpen,
onOpenChange,
}: TemplateFolderDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
}),
});
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
const form = useForm<TDeleteFolderFormSchema>({
resolver: zodResolver(ZDeleteFolderFormSchema),
defaultValues: {
confirmText: '',
},
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
});
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to delete does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Folder</DialogTitle>
<DialogDescription>
Are you sure you want to delete this folder?
{folder && folder._count.documents > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.documents} document(s). Deleting it will also
delete all documents in the folder.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
delete all subfolders and their contents.
</span>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={deleteMessage} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
Delete
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,175 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TemplateFolderMoveDialogProps = {
foldersData: TFolderWithSubfolders[] | undefined;
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveFolderFormSchema = z.object({
targetFolderId: z.string().optional(),
});
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
export const TemplateFolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: TemplateFolderMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
defaultValues: {
targetFolderId: folder?.parentId ?? '',
},
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return;
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId ?? '',
});
onOpenChange(false);
toast({
title: 'Folder moved successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to move does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-2">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(undefined)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={
form.formState.isSubmitting ||
form.getValues('targetFolderId') === folder?.parentId
}
>
Move Folder
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,176 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { FolderType } from '@documenso/lib/types/folder-type';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateFolderSettingsDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const TemplateFolderSettingsDialog = ({
folder,
isOpen,
onOpenChange,
}: TemplateFolderSettingsDialogProps) => {
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const isTemplateFolder = folder?.type === FolderType.TEMPLATE;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
name: folder?.name ?? '',
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) return;
try {
await updateFolder({
id: folder.id,
name: data.name,
visibility:
isTeamContext && !isTemplateFolder
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
title: _(msg`Folder updated successfully`),
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Folder not found`),
});
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Folder Settings</DialogTitle>
<DialogDescription>Manage the settings for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isTeamContext && !isTemplateFolder && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
Managers and above
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,158 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateMoveDialogProps = {
templateId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onMove?: ({
templateId,
teamUrl,
}: {
templateId: number;
teamUrl: string;
}) => Promise<void> | void;
};
export const TemplateMoveDialog = ({
templateId,
open,
onOpenChange,
onMove,
}: TemplateMoveDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: async () => {
const team = teams?.find((team) => team.id === selectedTeamId);
if (team) {
await onMove?.({ templateId, teamUrl: team.url });
}
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (err) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => msg`Template not found or already associated with a team.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`)
.otherwise(() => msg`An error occurred while moving the template.`);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
},
});
const handleOnMove = async () => {
if (!selectedTeamId) {
return;
}
await moveTemplate({ templateId, teamId: selectedTeamId });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Team</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder={_(msg`Select a team`)} />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
<Trans>Loading teams...</Trans>
</SelectItem>
) : (
teams?.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
)}
<AvatarFallback className="text-sm text-gray-400">
{team.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{team.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={handleOnMove}
loading={isPending}
disabled={!selectedTeamId || isPending}
>
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,213 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await moveTemplateToFolder({
templateId,
folderId: data.folderId ?? null,
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
</Button>
{folders?.data?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -30,7 +30,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type TokenDeleteDialogProps = {
token: Pick<ApiToken, 'id' | 'name'>;
@ -42,7 +42,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [isOpen, setIsOpen] = useState(false);

View File

@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -34,11 +34,11 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
@ -50,7 +50,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
@ -78,7 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
eventTriggers,
secret,
webhookUrl,
teamId: team?.id,
teamId: team.id,
});
setOpen(false);

View File

@ -30,7 +30,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type WebhookDeleteDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
@ -42,7 +42,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
const onSubmit = async () => {
try {
await deleteWebhook({ id: webhook.id, teamId: team?.id });
await deleteWebhook({ id: webhook.id, teamId: team.id });
toast({
title: _(msg`Webhook deleted`),

View File

@ -0,0 +1,355 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
interface ConfigureDocumentAdvancedSettingsProps {
control: Control<TConfigureEmbedFormSchema>;
isSubmitting: boolean;
}
export const ConfigureDocumentAdvancedSettings = ({
control,
isSubmitting,
}: ConfigureDocumentAdvancedSettingsProps) => {
const { _ } = useLingui();
const form = useFormContext<TConfigureEmbedFormSchema>();
const { features } = useConfigureDocument();
const { watch, setValue } = form;
// Lift watch() calls to reduce re-renders
const distributionMethod = watch('meta.distributionMethod');
const emailSettings = watch('meta.emailSettings');
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<Trans>Advanced Settings</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Configure additional options and preferences</Trans>
</p>
<Tabs defaultValue="general">
<TabsList className="mb-6 inline-flex">
<TabsTrigger value="general" className="px-4">
<Trans>General</Trans>
</TabsTrigger>
{features.allowConfigureCommunication && (
<TabsTrigger value="communication" className="px-4">
<Trans>Communication</Trans>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="general" className="mt-0">
<div className="flex flex-col space-y-6">
{/* <FormField
control={control}
name="meta.externalId"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>External ID</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add an external ID to the document. This can be used to identify the
document in external systems.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
{features.allowConfigureSignatureTypes && (
<FormField
control={control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Allowed Signature Types</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureLanguage && (
<FormField
control={control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Language</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureDateFormat && (
<FormField
control={control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureTimezone && (
<FormField
control={control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureRedirectUrl && (
<FormField
control={control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Redirect URL</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</TabsContent>
{features.allowConfigureCommunication && (
<TabsContent value="communication" className="mt-0">
<div className="flex flex-col space-y-6">
<FormField
control={control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Distribution Method</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DocumentDistributionMethod.EMAIL}>
<Trans>Email</Trans>
</SelectItem>
<SelectItem value={DocumentDistributionMethod.NONE}>
<Trans>None</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Choose how to distribute your document to recipients. Email will send
notifications, None will generate signing links for manual distribution.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<fieldset
className="flex flex-col space-y-6 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!isEmailDistribution}
>
<FormField
control={control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="subject">
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="message">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentSendEmailMessageHelper />
<DocumentEmailCheckboxes
className={`mt-2 ${!isEmailDistribution ? 'pointer-events-none' : ''}`}
value={emailSettings}
onChange={(value) => setValue('meta.emailSettings', value)}
/>
</fieldset>
</div>
</TabsContent>
)}
</Tabs>
</div>
);
};

View File

@ -0,0 +1,68 @@
import { createContext, useContext } from 'react';
export type ConfigureDocumentContext = {
// General
isTemplate: boolean;
isPersisted: boolean;
// Features
features: {
allowConfigureSignatureTypes?: boolean;
allowConfigureLanguage?: boolean;
allowConfigureDateFormat?: boolean;
allowConfigureTimezone?: boolean;
allowConfigureRedirectUrl?: boolean;
allowConfigureCommunication?: boolean;
};
};
const ConfigureDocumentContext = createContext<ConfigureDocumentContext | null>(null);
export type ConfigureDocumentProviderProps = {
isTemplate?: boolean;
isPersisted?: boolean;
features: {
allowConfigureSignatureTypes?: boolean;
allowConfigureLanguage?: boolean;
allowConfigureDateFormat?: boolean;
allowConfigureTimezone?: boolean;
allowConfigureRedirectUrl?: boolean;
allowConfigureCommunication?: boolean;
};
children: React.ReactNode;
};
export const ConfigureDocumentProvider = ({
isTemplate,
isPersisted,
features,
children,
}: ConfigureDocumentProviderProps) => {
return (
<ConfigureDocumentContext.Provider
value={{
isTemplate: isTemplate ?? false,
isPersisted: isPersisted ?? false,
features: {
allowConfigureSignatureTypes: features.allowConfigureSignatureTypes ?? true,
allowConfigureLanguage: features.allowConfigureLanguage ?? true,
allowConfigureDateFormat: features.allowConfigureDateFormat ?? true,
allowConfigureTimezone: features.allowConfigureTimezone ?? true,
allowConfigureRedirectUrl: features.allowConfigureRedirectUrl ?? true,
allowConfigureCommunication: features.allowConfigureCommunication ?? true,
},
}}
>
{children}
</ConfigureDocumentContext.Provider>
);
};
export const useConfigureDocument = () => {
const context = useContext(ConfigureDocumentContext);
if (!context) {
throw new Error('useConfigureDocument must be used within a ConfigureDocumentProvider');
}
return context;
};

View File

@ -0,0 +1,413 @@
import { useCallback, useRef } from 'react';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVertical, HelpCircle, Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
// Define a type for signer items
type SignerItem = TConfigureEmbedFormSchema['signers'][number];
export interface ConfigureDocumentRecipientsProps {
control: Control<TConfigureEmbedFormSchema>;
isSubmitting: boolean;
}
export const ConfigureDocumentRecipients = ({
control,
isSubmitting,
}: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const $sensorApi = useRef<SensorAPI | null>(null);
const {
fields: signers,
append: appendSigner,
remove: removeSigner,
replace,
move,
} = useFieldArray({
control,
name: 'signers',
});
const { getValues, watch, setValue } = useFormContext<TConfigureEmbedFormSchema>();
const signingOrder = watch('meta.signingOrder');
const { errors } = useFormState({
control,
});
const onAddSigner = useCallback(() => {
const signerNumber = signers.length + 1;
const recipientSigningOrder =
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
appendSigner({
formId: nanoid(8),
name: isTemplate ? `Recipient ${signerNumber}` : '',
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder:
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
});
}, [appendSigner, signers]);
const isSigningOrderEnabled = signingOrder === DocumentSigningOrder.SEQUENTIAL;
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
// Get current form values to preserve unsaved input data
const currentSigners = getValues('signers') || [...signers];
const signer = currentSigners[index];
// Remove signer from current position and insert at new position
const remainingSigners = currentSigners.filter((_: unknown, idx: number) => idx !== index);
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
remainingSigners.splice(newPosition, 0, signer);
// Update signing order for each item
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
...s,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
}));
// Update the form
replace(updatedSigners);
},
[signers, replace, getValues],
);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
// Use the move function from useFieldArray which preserves input values
move(result.source.index, result.destination.index);
// Update signing orders after move
const currentSigners = getValues('signers');
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
...signer,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
}));
// Update the form with new ordering
replace(updatedSigners);
},
[move, replace, getValues],
);
const onSigningOrderChange = (signingOrder: DocumentSigningOrder) => {
setValue('meta.signingOrder', signingOrder);
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
signers.forEach((_signer, index) => {
setValue(`signers.${index}.signingOrder`, index + 1);
});
}
};
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<Trans>Recipients</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Add signers and configure signing preferences</Trans>
</p>
<FormField
control={control}
name="meta.signingOrder"
render={({ field }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
onSigningOrderChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
}
disabled={isSubmitting}
/>
</FormControl>
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
</FormItem>
)}
/>
<FormField
control={control}
name="meta.allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
disabled={isSubmitting || !isSigningOrderEnabled}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence instead
of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
{signers.map((signer, index) => (
<Draggable
key={signer.id}
draggableId={signer.id}
index={index}
isDragDisabled={!isSigningOrderEnabled || isSubmitting || signer.disabled}
>
{(provided, snapshot) => (
<fieldset
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
disabled={signer.disabled}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
})}
>
<motion.div
className={cn('flex items-end gap-2 pb-2', {
'border-destructive/50': errors?.signers?.[index],
})}
>
{isSigningOrderEnabled && (
<FormField
control={control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('flex w-16 flex-none items-center gap-x-1', {
'mb-6':
errors?.signers?.[index] &&
!errors?.signers?.[index]?.signingOrder,
})}
>
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
min={1}
className="w-full text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
{...field}
disabled={isSubmitting || snapshot.isDragging}
onChange={(e) => {
field.onChange(e);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('flex-1', {
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.name,
})}
>
<FormLabel className="sr-only">
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
placeholder={_(msg`Name`)}
className="w-full"
{...field}
disabled={isSubmitting || snapshot.isDragging}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('flex-1', {
'mb-6':
errors?.signers?.[index] && !errors?.signers?.[index]?.email,
})}
>
<FormLabel className="sr-only">
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
type="email"
placeholder={_(msg`Email`)}
className="w-full"
{...field}
disabled={isSubmitting || snapshot.isDragging}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('flex-none', {
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.role,
})}
>
<FormLabel className="sr-only">
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderEnabled}
onValueChange={field.onChange}
disabled={isSubmitting || snapshot.isDragging || signer.disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
disabled={
isSubmitting ||
signers.length === 1 ||
snapshot.isDragging ||
signer.disabled
}
onClick={() => removeSigner(index)}
>
<Trash className="h-4 w-4" />
</Button>
</motion.div>
</fieldset>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="outline"
className="w-auto"
disabled={isSubmitting}
onClick={onAddSigner}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
<Trans>Add Signer</Trans>
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,238 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Cloud, FileText, Loader, X } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
export interface ConfigureDocumentUploadProps {
isSubmitting?: boolean;
}
export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocumentUploadProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { isPersisted } = useConfigureDocument();
const form = useFormContext<TConfigureEmbedFormSchema>();
const [isLoading, setIsLoading] = useState(false);
// Watch the documentData field from the form
const documentData = form.watch('documentData');
const onFileDrop = async (acceptedFiles: File[]) => {
try {
const file = acceptedFiles[0];
if (!file) {
return;
}
setIsLoading(true);
// Convert file to UInt8Array
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Store file metadata and UInt8Array in form data
form.setValue('documentData', {
name: file.name,
type: file.type,
size: file.size,
data: uint8Array, // Store as UInt8Array
});
// Auto-populate title if it's empty
const currentTitle = form.getValues('title');
if (!currentTitle) {
// Get filename without extension
const fileNameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
form.setValue('title', fileNameWithoutExtension);
}
} catch (error) {
console.error('Error uploading file', error);
toast({
title: _(msg`Error uploading file`),
description: _(msg`There was an error uploading your file. Please try again.`),
variant: 'destructive',
duration: 5000,
});
} finally {
setIsLoading(false);
}
};
const onDropRejected = () => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000,
variant: 'destructive',
});
};
const onRemoveFile = () => {
if (isPersisted) {
toast({
title: _(msg`Cannot remove document`),
description: _(msg`The document is already saved and cannot be changed.`),
duration: 5000,
variant: 'destructive',
});
return;
}
form.unregister('documentData');
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
maxSize: APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024,
multiple: false,
disabled: isSubmitting || isLoading || isPersisted,
onDrop: (files) => {
void onFileDrop(files);
},
onDropRejected,
});
return (
<div>
<FormField
control={form.control}
name="documentData"
render={() => (
<FormItem>
<FormLabel required>
<Trans>Upload Document</Trans>
</FormLabel>
<div className="relative">
{!documentData ? (
<div className="relative">
<FormControl>
<div
{...getRootProps()}
className={cn(
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
{
'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30':
!isDragActive && !isSubmitting && !isLoading && !isPersisted,
'cursor-not-allowed opacity-60': isSubmitting || isLoading || isPersisted,
},
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center gap-y-2 px-4 py-4 text-center">
<Cloud
className={cn('h-10 w-10', {
'text-primary': isDragActive,
'text-muted-foreground': !isDragActive,
})}
/>
<div
className={cn('flex flex-col space-y-1', {
'text-primary': isDragActive,
'text-muted-foreground': !isDragActive,
})}
>
<p className="text-sm font-medium">
{isDragActive ? (
<Trans>Drop your document here</Trans>
) : isPersisted ? (
<Trans>Document is already uploaded</Trans>
) : (
<Trans>Drag and drop or click to upload</Trans>
)}
</p>
<p className="text-xs">
{isPersisted ? (
<Trans>This document cannot be changed</Trans>
) : (
<Trans>
.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)
</Trans>
)}
</p>
</div>
</div>
</div>
</FormControl>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
</div>
)}
</div>
) : (
<div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
<FileText className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div>
<div className="text-muted-foreground text-xs">
{formatFileSize(documentData.size)}
</div>
</div>
{!isPersisted && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onRemoveFile}
disabled={isSubmitting}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};

View File

@ -0,0 +1,137 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from 'nanoid';
import { useForm } from 'react-hook-form';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { ConfigureDocumentAdvancedSettings } from './configure-document-advanced-settings';
import { useConfigureDocument } from './configure-document-context';
import { ConfigureDocumentRecipients } from './configure-document-recipients';
import { ConfigureDocumentUpload } from './configure-document-upload';
import {
type TConfigureEmbedFormSchema,
ZConfigureEmbedFormSchema,
} from './configure-document-view.types';
export interface ConfigureDocumentViewProps {
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
defaultValues?: Partial<TConfigureEmbedFormSchema>;
disableUpload?: boolean;
isSubmitting?: boolean;
}
export const ConfigureDocumentView = ({
onSubmit,
defaultValues,
disableUpload,
}: ConfigureDocumentViewProps) => {
const { isTemplate } = useConfigureDocument();
const form = useForm<TConfigureEmbedFormSchema>({
resolver: zodResolver(ZConfigureEmbedFormSchema),
defaultValues: {
title: defaultValues?.title || '',
signers: defaultValues?.signers || [
{
formId: nanoid(8),
name: isTemplate ? `Recipient ${1}` : '',
email: isTemplate ? `recipient.${1}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder: 1,
disabled: false,
},
],
meta: {
subject: defaultValues?.meta?.subject || '',
message: defaultValues?.meta?.message || '',
distributionMethod:
defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
emailSettings: defaultValues?.meta?.emailSettings || ZDocumentEmailSettingsSchema.parse({}),
dateFormat: defaultValues?.meta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT,
timezone: defaultValues?.meta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE,
redirectUrl: defaultValues?.meta?.redirectUrl || '',
language: defaultValues?.meta?.language || 'en',
signatureTypes: defaultValues?.meta?.signatureTypes || [],
signingOrder: defaultValues?.meta?.signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: defaultValues?.meta?.allowDictateNextSigner || false,
externalId: defaultValues?.meta?.externalId || '',
},
documentData: defaultValues?.documentData,
},
});
const { control, handleSubmit } = form;
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = handleSubmit(onSubmit);
return (
<div className="flex w-full flex-col space-y-8">
<div>
<h2 className="text-foreground mb-1 text-xl font-semibold">
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
</h2>
<p className="text-muted-foreground text-sm">
{isTemplate ? (
<Trans>Set up your template properties and recipient information</Trans>
) : (
<Trans>Set up your document properties and recipient information</Trans>
)}
</p>
</div>
<Form {...form}>
<div className="flex flex-col space-y-8">
<div>
<FormField
control={control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!disableUpload && <ConfigureDocumentUpload isSubmitting={isSubmitting} />}
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
<div className="flex justify-end">
<Button
type="button"
onClick={onFormSubmit}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
<Trans>Continue</Trans>
</Button>
</div>
</div>
</Form>
</div>
);
};

View File

@ -0,0 +1,50 @@
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/trpc/server/document-router/schema';
// Define the schema for configuration
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
export const ZConfigureEmbedFormSchema = z.object({
title: z.string().min(1, { message: 'Title is required' }),
signers: z
.array(
z.object({
nativeId: z.number().optional(),
formId: z.string(),
name: z.string().min(1, { message: 'Name is required' }),
email: z.string().email('Invalid email address'),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
}),
)
.min(1, { message: 'At least one signer is required' }),
meta: z.object({
subject: z.string().optional(),
message: z.string().optional(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
emailSettings: ZDocumentEmailSettingsSchema,
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
timezone: z.string().min(1, 'Timezone is required'),
redirectUrl: z.string().optional(),
language: ZDocumentMetaLanguageSchema.optional(),
signatureTypes: z.array(z.string()).default([]),
signingOrder: z.enum(['SEQUENTIAL', 'PARALLEL']),
allowDictateNextSigner: z.boolean().default(false).optional(),
externalId: z.string().optional(),
}),
documentData: z
.object({
name: z.string(),
type: z.string(),
size: z.number(),
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
})
.optional(),
});

View File

@ -0,0 +1,672 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { base64 } from '@documenso/lib/universal/base64';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form';
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
documentData?: DocumentData;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
};
export const ConfigureFieldsView = ({
configData,
documentData,
defaultValues,
onBack,
onSubmit,
}: ConfigureFieldsViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
// Track if we're on a mobile device
const [isMobile, setIsMobile] = useState(false);
// State for managing the mobile drawer
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
// Check for mobile viewport on component mount and resize
useEffect(() => {
const checkIfMobile = () => {
setIsMobile(window.innerWidth < 768);
};
// Initial check
checkIfMobile();
// Add resize listener
window.addEventListener('resize', checkIfMobile);
// Cleanup
return () => {
window.removeEventListener('resize', checkIfMobile);
};
}, []);
const normalizedDocumentData = useMemo(() => {
if (documentData) {
return documentData;
}
if (!configData.documentData) {
return null;
}
const data = base64.encode(configData.documentData?.data);
return {
id: 'preview',
type: 'BYTES_64',
data,
initialData: data,
} satisfies DocumentData;
}, [configData.documentData]);
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
id: signer.nativeId || index,
name: signer.name || '',
email: signer.email || '',
role: signer.role,
signingOrder: signer.signingOrder || null,
documentId: null,
templateId: null,
token: '',
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
}));
}, [configData.signers]);
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
const [lastActiveField, setLastActiveField] = useState<
TConfigureFieldsFormSchema['fields'][0] | null
>(null);
const [fieldClipboard, setFieldClipboard] = useState<
TConfigureFieldsFormSchema['fields'][0] | null
>(null);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(
null,
);
const fieldBounds = useRef({
height: DEFAULT_HEIGHT_PX,
width: DEFAULT_WIDTH_PX,
});
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
const selectedRecipientStyles = useRecipientColors(
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
);
const form = useForm<TConfigureFieldsFormSchema>({
defaultValues: {
fields: defaultValues?.fields ?? [],
},
});
const { control, handleSubmit } = form;
const onFormSubmit = handleSubmit(onSubmit);
const {
append,
remove,
update,
fields: localFields,
} = useFieldArray({
control: control,
name: 'fields',
});
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (duplicate) {
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageNumber,
};
append(newField);
});
return;
}
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
);
const onFieldPaste = useCallback(
(event: KeyboardEvent) => {
if (fieldClipboard) {
event.preventDefault();
const copiedField = structuredClone(fieldClipboard);
append({
...copiedField,
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? copiedField.signerEmail,
recipientId: selectedRecipient?.id ?? copiedField.recipientId,
pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3,
});
}
},
[append, fieldClipboard, selectedRecipient?.email, selectedRecipient?.id],
);
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt));
useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true }));
const onMouseMove = useCallback(
(event: MouseEvent) => {
if (!selectedField) return;
setIsFieldWithinBounds(
isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
),
);
setCoords({
x: event.clientX - fieldBounds.current.width / 2,
y: event.clientY - fieldBounds.current.height / 2,
});
},
[isWithinPageBounds, selectedField],
);
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedField || !selectedRecipient) {
return;
}
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
if (
!$page ||
!isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
)
) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
let pageX = ((event.pageX - left) / width) * 100;
let pageY = ((event.pageY - top) / height) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
// And center it based on the bounds
pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2;
const field = {
formId: nanoid(12),
type: selectedField,
pageNumber,
pageX,
pageY,
pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight,
recipientId: selectedRecipient.id,
signerEmail: selectedRecipient.email,
fieldMeta: undefined,
};
append(field);
// Automatically open advanced settings for field types that need configuration
if (ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING.includes(selectedField)) {
setCurrentField(field);
setShowAdvancedSettings(true);
}
setSelectedField(null);
},
[append, getPage, isWithinPageBounds, selectedField, selectedRecipient],
);
const onFieldResize = useCallback(
(node: HTMLElement, index: number) => {
const field = localFields[index];
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const {
x: pageX,
y: pageY,
width: pageWidth,
height: pageHeight,
} = getFieldPosition($page, node);
update(index, {
...field,
pageX,
pageY,
pageWidth,
pageHeight,
});
},
[getFieldPosition, localFields, update],
);
const onFieldMove = useCallback(
(node: HTMLElement, index: number) => {
const field = localFields[index];
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { x: pageX, y: pageY } = getFieldPosition($page, node);
update(index, {
...field,
pageX,
pageY,
});
},
[getFieldPosition, localFields, update],
);
const handleUpdateFieldMeta = useCallback(
(formId: string, fieldMeta: TFieldMetaSchema) => {
const fieldIndex = localFields.findIndex((field) => field.formId === formId);
if (fieldIndex !== -1) {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
update(fieldIndex, {
...localFields[fieldIndex],
fieldMeta: parsedFieldMeta,
});
}
},
[localFields, update],
);
useEffect(() => {
if (selectedField) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseClick);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => {
const observer = new MutationObserver((_mutations) => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
}
fieldBounds.current = {
height: Math.max(DEFAULT_HEIGHT_PX),
width: Math.max(DEFAULT_WIDTH_PX),
};
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, []);
// Close drawer when a field is selected on mobile
useEffect(() => {
if (isMobile && selectedField) {
setIsDrawerOpen(false);
}
}, [isMobile, selectedField]);
return (
<>
<div className="grid w-full grid-cols-12 gap-4">
{/* Desktop sidebar */}
{!isMobile && (
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
<div className="bg-widget border-border sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border p-4 pb-6">
<h2 className="mb-1 text-lg font-medium">
<Trans>Configure Fields</Trans>
</h2>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
<RecipientSelector
selectedRecipient={selectedRecipient}
onSelectedRecipientChange={setSelectedRecipient}
recipients={recipients}
className="w-full"
/>
<hr className="my-6" />
<div className="space-y-2">
<FieldSelector
selectedField={selectedField}
onSelectedFieldChange={setSelectedField}
className="w-full"
disabled={!selectedRecipient}
/>
</div>
<div className="mt-6 flex gap-2">
<Button
type="button"
variant="ghost"
className="flex-1"
loading={form.formState.isSubmitting}
onClick={() => onBack(form.getValues())}
>
<Trans>Back</Trans>
</Button>
<Button
className="flex-1"
type="button"
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
onClick={async () => onFormSubmit()}
>
<Trans>Save</Trans>
</Button>
</div>
</div>
</div>
)}
<div className={cn('order-1 col-span-12 md:order-2', !isMobile && 'md:col-span-8')}>
<div className="relative">
{selectedField && (
<div
className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]',
selectedRecipientStyles.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
selectedField === 'SIGNATURE' && 'font-signature',
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{_(FRIENDLY_FIELD_TYPE[selectedField])}
</span>
</div>
)}
<Form {...form}>
{normalizedDocumentData && (
<div>
<PDFViewer documentData={normalizedDocumentData} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex(
(r) => r.id === field.recipientId,
);
return (
<FieldItem
key={field.formId}
field={field}
minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX}
defaultWidth={DEFAULT_WIDTH_PX}
onResize={(node) => onFieldResize(node, index)}
onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => {
setCurrentField(field);
setShowAdvancedSettings(true);
}}
recipientIndex={recipientIndex}
active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)}
disabled={selectedRecipient?.id !== field.recipientId}
/>
);
})}
</ElementVisible>
</div>
)}
</Form>
</div>
</div>
</div>
{/* Mobile Floating Action Bar and Drawer */}
{isMobile && (
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<SheetTrigger asChild>
<div className="bg-widget border-border fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border p-4">
<span className="text-lg font-medium">
<Trans>Configure Fields</Trans>
</span>
<button
type="button"
className="border-border text-muted-foreground inline-flex h-10 w-10 items-center justify-center rounded-lg border"
>
<ChevronsUpDown className="h-6 w-6" />
</button>
</div>
</SheetTrigger>
<SheetContent
position="bottom"
size="xl"
className="bg-widget h-fit max-h-[80vh] overflow-y-auto rounded-t-xl p-4"
>
<h2 className="mb-1 text-lg font-medium">
<Trans>Configure Fields</Trans>
</h2>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
<RecipientSelector
selectedRecipient={selectedRecipient}
onSelectedRecipientChange={setSelectedRecipient}
recipients={recipients}
className="w-full"
/>
<hr className="my-6" />
<div className="space-y-2">
<FieldSelector
selectedField={selectedField}
onSelectedFieldChange={(field) => {
setSelectedField(field);
if (field) {
setIsDrawerOpen(false);
}
}}
className="w-full"
disabled={!selectedRecipient}
/>
</div>
<div className="mt-6 flex gap-2">
<Button
type="button"
variant="ghost"
className="flex-1"
loading={form.formState.isSubmitting}
onClick={() => onBack(form.getValues())}
>
<Trans>Back</Trans>
</Button>
<Button
className="flex-1"
type="button"
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
onClick={async () => onFormSubmit()}
>
<Trans>Save</Trans>
</Button>
</div>
</SheetContent>
</Sheet>
)}
<FieldAdvancedSettingsDrawer
isOpen={showAdvancedSettings}
onOpenChange={setShowAdvancedSettings}
currentField={currentField}
fields={localFields}
onFieldUpdate={handleUpdateFieldMeta}
/>
</>
);
};

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
export const ZConfigureFieldsFormSchema = z.object({
fields: z.array(
z.object({
nativeId: z.number().optional(),
formId: z.string().min(1),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
inserted: z.boolean().optional(),
recipientId: z.number().min(0),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema.optional(),
}),
),
});
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
export type TConfigureFieldsFormSchemaField = z.infer<
typeof ZConfigureFieldsFormSchema
>['fields'][number];

View File

@ -0,0 +1,60 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/field-item-advanced-settings';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
import type { TConfigureFieldsFormSchemaField } from './configure-fields-view.types';
export type FieldAdvancedSettingsDrawerProps = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
currentField: TConfigureFieldsFormSchemaField | null;
fields: TConfigureFieldsFormSchemaField[];
onFieldUpdate: (formId: string, fieldMeta: FieldMeta) => void;
};
export const FieldAdvancedSettingsDrawer = ({
isOpen,
onOpenChange,
currentField,
fields,
onFieldUpdate,
}: FieldAdvancedSettingsDrawerProps) => {
const { _ } = useLingui();
if (!currentField) {
return null;
}
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent position="right" size="lg" className="w-9/12 max-w-sm overflow-y-auto">
<SheetTitle className="sr-only">
{parseMessageDescriptor(
_,
msg`Configure ${parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])} Field`,
)}
</SheetTitle>
<FieldAdvancedSettings
title={msg`Advanced settings`}
description={msg`Configure the ${parseMessageDescriptor(
_,
FRIENDLY_FIELD_TYPE[currentField.type],
)} field`}
field={currentField}
fields={fields}
onAdvancedSettings={() => onOpenChange(false)}
onSave={(fieldMeta) => {
onFieldUpdate(currentField.formId, fieldMeta);
onOpenChange(false);
}}
/>
</SheetContent>
</Sheet>
);
};

View File

@ -1,7 +1,11 @@
import { Loader } from 'lucide-react';
export const EmbedClientLoading = () => {
return (
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
Loading...
<Loader className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
</div>
);
};

View File

@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
@ -25,12 +25,11 @@ import type {
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
@ -69,16 +68,8 @@ export const EmbedDirectTemplateClientPage = ({
const [searchParams] = useSearchParams();
const {
fullName,
email,
signature,
signatureValid,
setFullName,
setEmail,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const { fullName, email, signature, setFullName, setEmail, setSignature } =
useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@ -194,10 +185,6 @@ export const EmbedDirectTemplateClientPage = ({
const onCompleteClick = async () => {
try {
if (hasSignatureField && !signatureValid) {
return;
}
const valid = validateFieldsInserted(pendingFields);
if (!valid) {
@ -345,7 +332,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
@ -419,34 +406,16 @@ export const EmbedDirectTemplateClientPage = ({
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
<SignaturePadDialog
className="mt-2"
disabled={isThrottled || isSubmitting}
disableAnimation
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
/>
</div>
)}
</div>

View File

@ -10,7 +10,6 @@ export type EmbedDocumentCompletedPageProps = {
};
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">

View File

@ -54,6 +54,8 @@ export const EmbedDocumentFields = ({
onSignField={onSignField}
onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (

View File

@ -15,19 +15,20 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
@ -48,6 +49,7 @@ export type EmbedSignDocumentClientPageProps = {
documentData: DocumentData;
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
@ -61,6 +63,7 @@ export const EmbedSignDocumentClientPage = ({
documentData,
recipient,
fields,
completedFields,
metadata,
isCompleted,
hidePoweredBy = false,
@ -70,15 +73,8 @@ export const EmbedSignDocumentClientPage = ({
const { _ } = useLingui();
const { toast } = useToast();
const {
fullName,
email,
signature,
signatureValid,
setFullName,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const { fullName, email, signature, setFullName, setSignature } =
useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@ -93,6 +89,8 @@ export const EmbedSignDocumentClientPage = ({
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
@ -129,10 +127,6 @@ export const EmbedSignDocumentClientPage = ({
const onCompleteClick = async () => {
try {
if (hasSignatureField && !signatureValid) {
return;
}
const valid = validateFieldsInserted(fieldsRequiringValidation);
if (!valid) {
@ -214,6 +208,7 @@ export const EmbedSignDocumentClientPage = ({
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
@ -295,7 +290,7 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
@ -432,34 +427,16 @@ export const EmbedSignDocumentClientPage = ({
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
<SignaturePadDialog
className="mt-2"
disabled={isThrottled || isSubmitting}
disableAnimation
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
/>
</div>
)}
</>
@ -477,9 +454,7 @@ export const EmbedSignDocumentClientPage = ({
) : (
<Button
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
disabled={isThrottled}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
@ -500,6 +475,9 @@ export const EmbedSignDocumentClientPage = ({
{/* Fields */}
<EmbedDocumentFields fields={fields} metadata={metadata} />
{/* Completed fields */}
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
</div>
{!hidePoweredBy && (

View File

@ -0,0 +1,182 @@
import { Trans } from '@lingui/react/macro';
import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { ArrowRight, EyeIcon, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Progress } from '@documenso/ui/primitives/progress';
// Get the return type from getRecipientByToken
type RecipientWithFields = Awaited<ReturnType<typeof getRecipientByToken>>;
interface DocumentEnvelope {
document: DocumentAndSender;
recipient: RecipientWithFields;
}
interface MultiSignDocumentListProps {
envelopes: DocumentEnvelope[];
onDocumentSelect: (document: DocumentEnvelope['document']) => void;
}
export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSignDocumentListProps) {
// Calculate progress
const completedDocuments = envelopes.filter(
(envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED,
);
const totalDocuments = envelopes.length;
const progressPercentage = (completedDocuments.length / totalDocuments) * 100;
// Find next document to sign (first one that's not signed and not rejected)
const nextDocumentToSign = envelopes.find(
(envelope) =>
envelope.recipient.signingStatus !== SigningStatus.SIGNED &&
envelope.recipient.signingStatus !== SigningStatus.REJECTED,
);
const allDocumentsCompleted = completedDocuments.length === totalDocuments;
const hasAssistantOrCcRecipient = envelopes.some(
(envelope) =>
envelope.recipient.role === RecipientRole.ASSISTANT ||
envelope.recipient.role === RecipientRole.CC,
);
function handleView(doc: DocumentEnvelope['document']) {
onDocumentSelect(doc);
}
function handleNextDocument() {
if (nextDocumentToSign) {
onDocumentSelect(nextDocumentToSign.document);
}
}
if (hasAssistantOrCcRecipient) {
return (
<div className="mx-auto mt-16 flex w-full max-w-lg flex-col md:mt-16 md:rounded-2xl md:border md:px-8 md:py-16 md:shadow-lg">
<div className="flex items-center justify-center">
<XCircle className="text-destructive h-16 w-16 md:h-24 md:w-24" strokeWidth={1.2} />
</div>
<h2 className="mt-12 text-xl font-bold md:text-2xl">
<Trans>It looks like we ran into an issue!</Trans>
</h2>
<p className="text-muted-foreground mt-6">
<Trans>
One of the documents in the current bundle has a signing role that is not compatible
with the current signing experience.
</Trans>
</p>
<p className="text-muted-foreground mt-2">
<Trans>
Assistants and Copy roles are currently not compatible with the multi-sign experience.
</Trans>
</p>
<p className="text-muted-foreground mt-2">
<Trans>Please contact the site owner for further assistance.</Trans>
</p>
</div>
);
}
return (
<div className="bg-background mx-auto w-full max-w-lg md:my-12 md:rounded-2xl md:border md:p-8 md:shadow-lg">
<h2 className="text-foreground mb-1 text-lg font-semibold">
<Trans>Sign Documents</Trans>
</h2>
<p className="text-muted-foreground text-sm">
<Trans>
You have been requested to sign the following documents. Review each document carefully
and complete the signing process.
</Trans>
</p>
{/* Progress Section */}
<div className="mt-6">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground font-medium">
<Trans>Progress</Trans>
</span>
<span className="text-muted-foreground">
{completedDocuments.length} of {totalDocuments} completed
</span>
</div>
<div className="mt-4">
<Progress value={progressPercentage} className="h-2" />
</div>
</div>
<div className="mt-6 flex flex-col gap-4">
{envelopes.map((envelope) => (
<div
key={envelope.document.id}
className="border-border flex items-center gap-4 rounded-lg border px-4 py-2"
>
<span className="text-foreground flex-1 truncate text-sm font-medium">
{envelope.document.title}
</span>
{match(envelope.recipient)
.with({ signingStatus: SigningStatus.SIGNED }, () => (
<Badge size="small" variant="default">
<Trans>Completed</Trans>
</Badge>
))
.with({ signingStatus: SigningStatus.REJECTED }, () => (
<Badge size="small" variant="destructive">
<Trans>Rejected</Trans>
</Badge>
))
.with({ readStatus: ReadStatus.OPENED }, () => (
<Badge size="small" variant="neutral">
<Trans>Viewed</Trans>
</Badge>
))
.otherwise(() => null)}
<Button
className="-mr-2"
variant="outline"
size="sm"
onClick={() => handleView(envelope.document)}
>
<EyeIcon className="mr-1 h-4 w-4" />
<Trans>View</Trans>
</Button>
</div>
))}
</div>
{/* Next Document Button */}
{!allDocumentsCompleted && nextDocumentToSign && (
<div className="mt-6">
<Button onClick={handleNextDocument} className="w-full" size="lg">
<Trans>View next document</Trans>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)}
{allDocumentsCompleted && (
<Alert className="mt-6 text-center">
<AlertTitle>
<Trans>All documents have been completed!</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Thank you for completing the signing process.</Trans>
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@ -0,0 +1,396 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
import { EmbedDocumentFields } from '../embed-document-fields';
interface MultiSignDocumentSigningViewProps {
token: string;
recipientId: number;
onBack: () => void;
onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void;
onDocumentRejected?: (data: {
token: string;
documentId: number;
recipientId: number;
reason: string;
}) => void;
onDocumentError?: () => void;
onDocumentReady?: () => void;
isNameLocked?: boolean;
allowDocumentRejection?: boolean;
}
export const MultiSignDocumentSigningView = ({
token,
recipientId,
onBack,
onDocumentCompleted,
onDocumentRejected,
onDocumentError,
onDocumentReady,
isNameLocked = false,
allowDocumentRejection = false,
}: MultiSignDocumentSigningViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { fullName, email, signature, setFullName, setSignature } =
useRequiredDocumentSigningContext();
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery(
{ token },
{
staleTime: 0,
},
);
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const { mutateAsync: removeSignedFieldWithToken } =
trpc.field.removeSignedFieldWithToken.useMutation();
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
[],
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
[],
];
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
try {
await signFieldWithToken(payload);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
};
const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
try {
await removeSignedFieldWithToken(payload);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
}
};
const onDocumentComplete = async () => {
try {
setIsSubmitting(true);
await completeDocumentWithToken({
documentId: document!.id,
token,
});
onBack();
onDocumentCompleted?.({
token,
documentId: document!.id,
recipientId,
});
} catch (err) {
onDocumentError?.();
toast({
title: 'Error',
description: 'Failed to complete the document. Please try again.',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
const onNextFieldClick = () => {
setShowPendingFieldTooltip(true);
setIsExpanded(false);
};
const onRejected = (reason: string) => {
if (onDocumentRejected && document) {
onDocumentRejected({
token,
documentId: document.id,
recipientId,
reason,
});
}
};
return (
<div className="bg-background min-h-screen overflow-hidden">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
{match({ isLoading, document })
.with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader className="text-primary h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm">
<Trans>Loading document...</Trans>
</p>
</div>
</div>
))
.with({ isLoading: false, document: undefined }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<p className="text-muted-foreground text-sm">
<Trans>Failed to load document</Trans>
</p>
</div>
))
.with({ document: P.nonNullable }, ({ document }) => (
<>
<div className="mx-auto flex w-full max-w-screen-xl items-baseline justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
</div>
<Button variant="ghost" size="sm" onClick={onBack} className="p-2">
<X className="h-5 w-5" />
</Button>
</div>
{allowDocumentRejection && (
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
document={document}
token={token}
onRejected={onRejected}
/>
</div>
)}
<div className="embed--DocumentContainer relative mx-auto mt-8 flex w-full max-w-screen-xl flex-col gap-x-6 gap-y-12 md:flex-row">
<div
className={cn('embed--DocumentViewer flex-1', {
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
})}
>
<PDFViewer
documentData={document.documentData}
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();
}}
/>
</div>
{/* Widget */}
{document.status !== DocumentStatus.COMPLETED && (
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-0 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
</div>
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
</div>
{/* Form */}
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
{
<>
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
type="email"
id="email"
className="bg-background mt-2"
value={email}
disabled
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
disableAnimation
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={
document.documentMeta?.typedSignatureEnabled
}
uploadSignatureEnabled={
document.documentMeta?.uploadSignatureEnabled
}
drawSignatureEnabled={
document.documentMeta?.drawSignatureEnabled
}
/>
</div>
)}
</>
}
</div>
</div>
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
{uninsertedFields.length > 0 ? (
<Button className="col-start-2" onClick={onNextFieldClick}>
<Trans>Next</Trans>
</Button>
) : (
<Button
className="col-span-2"
loading={isSubmitting}
onClick={onDocumentComplete}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
</div>
)}
</div>
{hasDocumentLoaded && (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip
key={pendingFields[0].id}
field={pendingFields[0]}
color="warning"
>
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
)}
{/* Fields */}
{hasDocumentLoaded && (
<EmbedDocumentFields
fields={pendingFields}
metadata={document.documentMeta}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
)}
{/* Completed fields */}
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
documentMeta={document.documentMeta ?? undefined}
fields={completedFields}
/>
)}
</>
))
.otherwise(() => null)}
</div>
</div>
);
};

View File

@ -6,7 +6,6 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ErrorCode, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
@ -29,8 +28,6 @@ import {
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export const ZAvatarImageFormSchema = z.object({
bytes: z.string().nullish(),
});
@ -39,29 +36,44 @@ export type TAvatarImageFormSchema = z.infer<typeof ZAvatarImageFormSchema>;
export type AvatarImageFormProps = {
className?: string;
team?: {
id: number;
name: string;
avatarImageId: string | null;
};
organisation?: {
id: string;
name: string;
avatarImageId: string | null;
};
};
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
export const AvatarImageForm = ({ className, team, organisation }: AvatarImageFormProps) => {
const { user, refreshSession } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const team = useOptionalCurrentTeam();
const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation();
const initials = extractInitials(team?.name || user.name || '');
const initials = extractInitials(team?.name || organisation?.name || user.name || '');
const hasAvatarImage = useMemo(() => {
if (team) {
return team.avatarImageId !== null;
}
return user.avatarImageId !== null;
}, [team, user.avatarImageId]);
if (organisation) {
return organisation.avatarImageId !== null;
}
const avatarImageId = team ? team.avatarImageId : user.avatarImageId;
return user.avatarImageId !== null;
}, [team, organisation, user.avatarImageId]);
const avatarImageId = team
? team.avatarImageId
: organisation
? organisation.avatarImageId
: user.avatarImageId;
const form = useForm<TAvatarImageFormSchema>({
values: {
@ -100,7 +112,8 @@ export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
try {
await setProfileImage({
bytes: data.bytes,
teamId: team?.id,
teamId: team?.id ?? null,
organisationId: organisation?.id ?? null,
});
await refreshSession();

View File

@ -1,17 +1,14 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { TeamGlobalSettings } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -23,15 +20,20 @@ import {
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZTeamBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean(),
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
brandingLogo: z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
@ -44,76 +46,45 @@ const ZTeamBrandingPreferencesFormSchema = z.object({
brandingCompanyDetails: z.string().max(500).optional(),
});
type TTeamBrandingPreferencesFormSchema = z.infer<typeof ZTeamBrandingPreferencesFormSchema>;
export type TBrandingPreferencesFormSchema = z.infer<typeof ZBrandingPreferencesFormSchema>;
export type TeamBrandingPreferencesFormProps = {
team: Team;
settings?: TeamGlobalSettings | null;
type SettingsSubset = Pick<
TeamGlobalSettings,
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails'
>;
export type BrandingPreferencesFormProps = {
canInherit?: boolean;
settings: SettingsSubset;
onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise<void>;
context: 'Team' | 'Organisation';
};
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
const { _ } = useLingui();
const { toast } = useToast();
export function BrandingPreferencesForm({
canInherit = false,
settings,
onFormSubmit,
context,
}: BrandingPreferencesFormProps) {
const { t } = useLingui();
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const { mutateAsync: updateTeamBrandingSettings } =
trpc.team.updateTeamBrandingSettings.useMutation();
const form = useForm<TTeamBrandingPreferencesFormSchema>({
const form = useForm<TBrandingPreferencesFormSchema>({
defaultValues: {
brandingEnabled: settings?.brandingEnabled ?? false,
brandingUrl: settings?.brandingUrl ?? '',
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
},
resolver: zodResolver(ZTeamBrandingPreferencesFormSchema),
resolver: zodResolver(ZBrandingPreferencesFormSchema),
});
const isBrandingEnabled = form.watch('brandingEnabled');
const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
let uploadedBrandingLogo = settings?.brandingLogo;
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
await updateTeamBrandingSettings({
teamId: team.id,
settings: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
},
});
toast({
title: _(msg`Branding preferences updated`),
description: _(msg`Your branding preferences have been updated`),
});
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to update your branding preferences at this time, please try again later`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (settings?.brandingLogo) {
if (settings.brandingLogo) {
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) {
@ -129,7 +100,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
}
setHasLoadedPreview(true);
}, [settings?.brandingLogo]);
}, [settings.brandingLogo]);
// Cleanup ObjectURL on unmount or when previewUrl changes
useEffect(() => {
@ -142,45 +113,72 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
disabled={form.formState.isSubmitting}
>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="brandingEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enable Custom Branding</FormLabel>
<FormLabel>
<Trans>Enable Custom Branding</Trans>
</FormLabel>
<div>
<FormControl>
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="enable-branding"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>Enable custom branding for all documents in this team.</Trans>
{context === 'Team' ? (
<Trans>Enable custom branding for all documents in this team</Trans>
) : (
<Trans>Enable custom branding for all documents in this organisation</Trans>
)}
</FormDescription>
</FormItem>
)}
/>
<div className="relative flex w-full flex-col gap-y-4">
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9999]" />}
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9998]" />}
<FormField
control={form.control}
name="brandingLogo"
render={({ field: { value: _value, onChange, ...field } }) => (
<FormItem className="flex-1">
<FormLabel>Branding Logo</FormLabel>
<FormLabel>
<Trans>Branding Logo</Trans>
</FormLabel>
<div className="flex flex-col gap-4">
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
@ -192,7 +190,8 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
/>
) : (
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
Please upload a logo
<Trans>Please upload a logo</Trans>
{!hasLoadedPreview && (
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
@ -253,6 +252,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<FormDescription>
<Trans>Upload your brand logo (max 5MB, JPG, PNG, or WebP)</Trans>
{canInherit && (
<span>
{'. '}
<Trans>Leave blank to inherit from the organisation.</Trans>
</span>
)}
</FormDescription>
</div>
</FormItem>
@ -264,7 +270,9 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
name="brandingUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Brand Website</FormLabel>
<FormLabel>
<Trans>Brand Website</Trans>
</FormLabel>
<FormControl>
<Input
@ -277,6 +285,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<FormDescription>
<Trans>Your brand website URL</Trans>
{canInherit && (
<span>
{'. '}
<Trans>Leave blank to inherit from the organisation.</Trans>
</span>
)}
</FormDescription>
</FormItem>
)}
@ -287,11 +302,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
name="brandingCompanyDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Brand Details</FormLabel>
<FormLabel>
<Trans>Brand Details</Trans>
</FormLabel>
<FormControl>
<Textarea
placeholder={_(msg`Enter your brand details`)}
placeholder={t`Enter your brand details`}
className="min-h-[100px] resize-y"
disabled={!isBrandingEnabled}
{...field}
@ -300,6 +317,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<FormDescription>
<Trans>Additional brand information to display at the bottom of emails</Trans>
{canInherit && (
<span>
{'. '}
<Trans>Leave blank to inherit from the organisation.</Trans>
</span>
)}
</FormDescription>
</FormItem>
)}
@ -308,7 +332,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>

View File

@ -0,0 +1,370 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
* it to be dynamic.
*/
export type TDocumentPreferencesFormSchema = {
documentVisibility: DocumentVisibility | null;
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null;
signatureTypes: DocumentSignatureType[];
};
type SettingsSubset = Pick<
TeamGlobalSettings,
| 'documentVisibility'
| 'documentLanguage'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
>;
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { user, organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const placeholderEmail = user.email ?? 'user@example.com';
const ZDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
});
const form = useForm<TDocumentPreferencesFormSchema>({
defaultValues: {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage)
? settings.documentLanguage
: null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full max-w-2xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
{!isPersonalLayoutMode && (
<FormField
control={form.control}
name="documentVisibility"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Visibility</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>
<Trans>Everyone can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<Trans>Only managers and above can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>
<Trans>Only admins can access and view the document</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>Controls the default visibility of an uploaded document.</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="documentLanguage"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Language</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls the default language of an uploaded document. This will be used as the
language in email communications with the recipients.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="signatureTypes"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="flex flex-row items-center">
<Trans>Default Signature Settings</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
enableSearch={false}
emptySelectionPlaceholder={
canInherit ? t`Inherit from organisation` : t`Select signature types`
}
testId="signature-types-combobox"
/>
</FormControl>
{form.formState.errors.signatureTypes ? (
<FormMessage />
) : (
<FormDescription>
<Trans>
Controls which signatures are allowed to be used when signing a document.
</Trans>
</FormDescription>
)}
</FormItem>
)}
/>
{!isPersonalLayoutMode && (
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Send on Behalf of Team</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<div className="pt-2">
<div className="text-muted-foreground text-xs font-medium">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{field.value ? (
<Trans>
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign
"example document".
</Trans>
) : (
<Trans>"Team Name" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a
recipient to sign a document. If a custom message has been provided while
configuring the document, it will be used instead.
</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Signing Certificate in the Document</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,182 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
name: true,
url: true,
});
type TOrganisationUpdateFormSchema = z.infer<typeof ZOrganisationUpdateFormSchema>;
export const OrganisationUpdateForm = () => {
const navigate = useNavigate();
const organisation = useCurrentOrganisation();
const { refreshSession } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm({
resolver: zodResolver(ZOrganisationUpdateFormSchema),
defaultValues: {
name: organisation.name,
url: organisation.url,
},
});
const { mutateAsync: updateOrganisation } = trpc.organisation.update.useMutation();
const onFormSubmit = async ({ name, url }: TOrganisationUpdateFormSchema) => {
try {
await updateOrganisation({
data: {
name,
url,
},
organisationId: organisation.id,
});
await refreshSession();
if (url !== organisation.url) {
await navigate(`/o/${url}/settings`);
}
toast({
title: _(msg`Success`),
description: _(msg`Your organisation has been successfully updated.`),
duration: 5000,
});
form.reset({
name,
url,
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
form.setError('url', {
type: 'manual',
message: _(msg`This URL is already in use.`),
});
return;
}
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to update your organisation. Please try again later.`,
),
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel required>
<Trans>Organisation URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.url && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}`
) : (
<Trans>A unique URL to identify your organisation</Trans>
)}
</span>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
<Trans>Reset</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Update organisation</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -19,12 +19,15 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZProfileFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
signature: z.string().min(1, 'Signature Pad cannot be empty'),
name: z
.string()
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
});
export const ZTwoFactorAuthTokenSchema = z.object({
@ -109,22 +112,20 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
</Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Signature</Trans>
</FormLabel>
<FormControl>
<SignaturePad
className="h-44 w-full"
<SignaturePadDialog
disabled={isSubmitting}
containerClassName={cn('rounded-lg border bg-background')}
defaultValue={user.signature ?? undefined}
value={value}
onChange={(v) => onChange(v ?? '')}
allowTypedSignature={true}
/>
</FormControl>
<FormMessage />
@ -134,7 +135,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
</fieldset>
<Button type="submit" loading={isSubmitting} className="self-end">
{isSubmitting ? <Trans>Updating profile...</Trans> : <Trans>Update profile</Trans>}
<Trans>Update profile</Trans>
</Button>
</form>
</Form>

View File

@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import type { TeamProfile, UserProfile } from '@prisma/client';
import type { TeamProfile } from '@prisma/client';
import { motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { CheckSquareIcon, CopyIcon } from 'lucide-react';
@ -12,12 +12,14 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError } from '@documenso/lib/errors/app-error';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
import {
MAX_PROFILE_BIO_LENGTH,
ZUpdatePublicProfileMutationSchema,
} from '@documenso/trpc/server/profile-router/schema';
ZUpdateTeamRequestSchema,
} from '@documenso/trpc/server/team-router/update-team.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -32,9 +34,10 @@ import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({
bio: true,
enabled: true,
import { useCurrentTeam } from '~/providers/team';
export const ZPublicProfileFormSchema = ZUpdateTeamRequestSchema.shape.data.pick({
profileBio: true,
url: true,
});
@ -42,16 +45,12 @@ export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileFormProps = {
className?: string;
profileUrl?: string | null;
teamUrl?: string;
onProfileUpdate: (data: TPublicProfileFormSchema) => Promise<unknown>;
profile: UserProfile | TeamProfile;
profile: TeamProfile;
};
export const PublicProfileForm = ({
className,
profileUrl,
profile,
teamUrl,
onProfileUpdate,
}: PublicProfileFormProps) => {
const { _ } = useLingui();
@ -61,10 +60,16 @@ export const PublicProfileForm = ({
const [copiedTimeout, setCopiedTimeout] = useState<NodeJS.Timeout | null>(null);
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const team = useCurrentTeam();
const form = useForm<TPublicProfileFormSchema>({
values: {
url: profileUrl ?? '',
bio: profile?.bio ?? '',
url: team.url,
profileBio: profile?.bio ?? '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
@ -83,12 +88,13 @@ export const PublicProfileForm = ({
form.reset({
url: data.url,
bio: data.bio,
profileBio: data.profileBio,
});
} catch (err) {
const error = AppError.parseError(err);
switch (error.code) {
case AppErrorCode.ALREADY_EXISTS:
case 'PREMIUM_PROFILE_URL':
case 'PROFILE_URL_TAKEN':
form.setError('url', {
@ -145,10 +151,10 @@ export const PublicProfileForm = ({
<Trans>Public profile URL</Trans>
</FormLabel>
<FormControl>
<Input {...field} disabled={field.disabled || teamUrl !== undefined} />
<Input {...field} disabled={field.disabled || !isPersonalLayoutMode} />
</FormControl>
{teamUrl && (
{!isPersonalLayoutMode && (
<p className="text-muted-foreground text-xs">
<Trans>
You can update the profile URL by updating the team URL in the general
@ -208,7 +214,7 @@ export const PublicProfileForm = ({
<FormField
control={form.control}
name="bio"
name="profileBio"
render={({ field }) => {
const remaningLength = MAX_PROFILE_BIO_LENGTH - (field.value || '').length;
@ -218,13 +224,11 @@ export const PublicProfileForm = ({
<FormControl>
<Textarea
{...field}
placeholder={
teamUrl ? _(msg`Write about the team`) : _(msg`Write about yourself`)
}
placeholder={_(msg`Write a description to display on your public profile`)}
/>
</FormControl>
{!form.formState.errors.bio && (
{!form.formState.errors.profileBio && (
<p className="text-muted-foreground text-sm">
{remaningLength >= 0 ? (
<Plural

View File

@ -55,7 +55,7 @@ const handleFallbackErrorMessages = (code: string) => {
return message;
};
const LOGIN_REDIRECT_PATH = '/documents';
const LOGIN_REDIRECT_PATH = '/';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),

View File

@ -1,11 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
@ -15,7 +14,6 @@ import { z } from 'zod';
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
@ -30,14 +28,11 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
import { UserProfileTimur } from '~/components/general/user-profile-timur';
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
export const ZSignUpFormSchema = z
.object({
name: z
@ -47,14 +42,6 @@ export const ZSignUpFormSchema = z
email: z.string().email().min(1),
password: ZPasswordSchema,
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: msg`We need a username to create your profile`.id })
.regex(/^[a-z0-9-]+$/, {
message: msg`Username can only container alphanumeric characters and dashes.`.id,
}),
})
.refine(
(data) => {
@ -71,8 +58,6 @@ export const signupErrorMessages: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signups are disabled.`,
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
PROFILE_URL_TAKEN: msg`This username has already been taken`,
PREMIUM_PROFILE_URL: msg`Only subscribers can have a username shorter than 6 characters`,
};
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
@ -97,19 +82,14 @@ export const SignUpForm = ({
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
const utmSrc = searchParams.get('utm_source') ?? null;
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TSignUpFormSchema>({
values: {
name: '',
email: initialEmail ?? '',
password: '',
signature: '',
url: '',
},
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormSchema),
@ -117,17 +97,13 @@ export const SignUpForm = ({
const isSubmitting = form.formState.isSubmitting;
const name = form.watch('name');
const url = form.watch('url');
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormSchema) => {
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
try {
await authClient.emailPassword.signUp({
name,
email,
password,
signature,
url,
});
await navigate(`/unverified-account`);
@ -150,26 +126,11 @@ export const SignUpForm = ({
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
if (error.code === 'PROFILE_URL_TAKEN' || error.code === 'PREMIUM_PROFILE_URL') {
form.setError('url', {
type: 'manual',
message: _(errorMessage),
});
} else {
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
});
}
}
};
const onNextClick = async () => {
const valid = await form.trigger(['name', 'email', 'password', 'signature']);
if (valid) {
setStep('CLAIM_USERNAME');
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
});
}
};
@ -231,59 +192,30 @@ export const SignUpForm = ({
<Trans>User profiles are here!</Trans>
</div>
<AnimatePresence>
{step === 'BASIC_DETAILS' ? (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
) : (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileSkeleton
user={{ name, url }}
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
)}
</AnimatePresence>
<div className="w-full max-w-md">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</div>
<div />
</div>
</div>
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
{step === 'BASIC_DETAILS' && (
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Create a new account</Trans>
</h1>
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Create a new account</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<Trans>
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</Trans>
</p>
</div>
)}
{step === 'CLAIM_USERNAME' && (
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Claim your username now</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<Trans>
You will get notified & be able to set up your documenso public profile when we
launch the feature.
</Trans>
</p>
</div>
)}
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<Trans>
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</Trans>
</p>
</div>
<hr className="-mx-6 my-4" />
@ -292,243 +224,146 @@ export const SignUpForm = ({
className="flex w-full flex-1 flex-col gap-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
{step === 'BASIC_DETAILS' && (
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Full Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Full Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<FormItem>
<FormLabel>
<Trans>Sign Here</Trans>
</FormLabel>
<FormControl>
<SignaturePad
className="h-36 w-full"
disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
</>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
</>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
</>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Sign Here</Trans>
</FormLabel>
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
Already have an account?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Sign in instead
</Link>
</Trans>
</p>
</fieldset>
)}
{step === 'CLAIM_USERNAME' && (
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[650px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Public profile username</Trans>
</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2 lowercase" {...field} />
</FormControl>
<FormMessage />
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block max-w-[16rem] truncate rounded-md border px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
)}
/>
</fieldset>
)}
<div className="mt-6">
{step === 'BASIC_DETAILS' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">
<Trans>Basic details</Trans>
</span>{' '}
1/2
</p>
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
</>
)}
{step === 'CLAIM_USERNAME' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">
<Trans>Claim username</Trans>
</span>{' '}
2/2
</p>
{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
</>
)}
<div className="bg-foreground/40 relative mt-4 h-1.5 rounded-full">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0 rounded-full"
style={{
width: step === 'BASIC_DETAILS' ? '50%' : '100%',
}}
/>
</div>
</div>
<div className="flex items-center gap-x-4">
{/* Go back button, disabled if step is basic details */}
<Button
type="button"
size="lg"
variant="secondary"
className="flex-1"
disabled={step === 'BASIC_DETAILS' || form.formState.isSubmitting}
onClick={() => setStep('BASIC_DETAILS')}
>
<Trans>Back</Trans>
</Button>
{/* Continue button */}
{step === 'BASIC_DETAILS' && (
<Button
type="button"
size="lg"
className="flex-1 disabled:cursor-not-allowed"
loading={form.formState.isSubmitting}
onClick={onNextClick}
>
<Trans>Next</Trans>
</Button>
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
</>
)}
{/* Sign up button */}
{step === 'CLAIM_USERNAME' && (
<Button
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
type="submit"
size="lg"
className="flex-1"
>
<Trans>Complete</Trans>
</Button>
)}
</div>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
Already have an account?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Sign in instead
</Link>
</Trans>
</p>
</fieldset>
<Button
loading={form.formState.isSubmitting}
type="submit"
size="lg"
className="mt-6 w-full"
>
<Trans>Complete</Trans>
</Button>
</form>
</Form>
<p className="text-muted-foreground mt-6 text-xs">

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