Compare commits

...

123 Commits

Author SHA1 Message Date
191b333e34 chore: implement feedback 2025-07-25 11:41:46 +03:00
2c579c6455 chore: merge main 2025-07-25 10:09:39 +03:00
6bd688bde4 chore: implement feedback 2025-07-25 10:05:26 +03:00
Tom
7cbf527eb3 chore: update French translations (#1762) 2025-07-25 10:52:18 +10:00
767b66672e chore: add translations (#1910) 2025-07-25 10:51:47 +10:00
109a49826c chore: extract translations 2025-07-24 16:15:34 +10:00
3409aae411 feat: add email domains (#1895)
Implemented Email Domains which allows Platform/Enterprise customers to
send emails to recipients using their custom emails.
2025-07-24 16:05:00 +10:00
07119f0e8d fix: correctly render new lines in text fields (#1920)
Currently new lines are not rendered in text fields correctly on the
`/sign` page. This is an issue because when the field is inserted and
sealed we respect new lines.
2025-07-24 14:30:33 +10:00
c0a72123bd chore: implement feedback 2025-07-23 14:42:16 +03:00
d710f53fb5 chore: merged main 2025-07-23 14:03:30 +03:00
7a5a9eefe8 feat: upload template via API (#1842)
Allow users to upload templates via both v1 and v2 APIs. Similar to
uploading documents.
2025-07-23 14:41:12 +10:00
5570690b3b fix: clicking on tooltip icon submit parent form (#1915) 2025-07-23 14:28:02 +10:00
9ea56a77ff v1.12.2-rc.2 2025-07-20 17:05:19 +10:00
32c94118ce fix: subscription update handler logic 2025-07-20 11:18:02 +10:00
512e3555b4 feat: horizontal checkboxes (#1911)
Adds the ability to have checkboxes align horizontally, wrapping when
they would go off the PDF
2025-07-19 22:06:50 +10:00
c47dc8749a fix: handle unauthorized document move error (#1884) 2025-07-16 14:45:12 +10:00
32a5d33a16 fix: invalid folder queries (#1898)
Currently the majority of folder mutations only work if the user is the
owner of the folder.
2025-07-16 14:37:55 +10:00
e5aaa17545 fix: restrict individual plans to upgrade only (#1900)
Prevent users from creating a separate organisation for individual
plans. Only applies to users who have 1 personal organisation and are
subscribing to the "Individual" plan.

The reason for this change is to keep the layout in the "Personal" mode
which means it doesn't show a bunch of unusable "organisation" related
UI.
2025-07-16 14:35:42 +10:00
f9d7fd7d9a fix: resend document api v1 filtering logic (#1888)
The resend document API was not working correctly when filtering
recipients. The query was filtering recipients at the database level,
which could result in an empty recipients array being returned even when
the document had recipients. This prevented the API from properly
identifying which recipients needed reminder emails.
2025-07-16 14:31:40 +10:00
5083ecb4b8 fix: allow resubscribing (#1901)
Currently users who cancel their plan are stuck without the ability to
resubscribe. This allows them to choose a plan to subscribe

This assumes that a Subscription in the "INACTIVE" state means that the
plan has been paid but canceled.

No tests have been done to determine the relation between "PAST_DUE" and
"INACTIVE" states within our context.
2025-07-16 14:26:21 +10:00
168648164b docs: add test webhook section (#1902) 2025-07-16 13:22:30 +10:00
202e9fedb9 fix: remove unsupported frontmatter from PULL_REQUEST_TEMPLATE.md (#1867) 2025-07-15 16:18:15 +10:00
939bbcdb33 docs: api rate limit (#1899) 2025-07-15 16:16:50 +10:00
70f6036525 chore: add translations (#1877) 2025-07-15 12:29:37 +10:00
122e25b491 feat: test webhook functionality (#1886) 2025-07-14 15:13:56 +10:00
ca9a70ced5 fix: handle trials and resubscribing (#1897) 2025-07-14 12:31:06 +10:00
55abecc526 fix: isAssistantMode was incorrectly set to true for regular recipients (#1854) 2025-07-13 22:41:18 +10:00
49c70fc8a8 chore: update docs 2025-07-11 17:02:10 +10:00
4195a871ce chore: update gitginore (#1894) 2025-07-11 13:16:51 +10:00
37ed5ad222 v1.12.2-rc.1 2025-07-11 12:55:56 +10:00
d6c11bd195 fix: sign-able readonly fields (#1885) 2025-07-10 16:47:36 +10:00
cb73d21e05 chore: api tests (#1856) 2025-07-10 12:56:46 +10:00
106f796fea fix: readonly field styling (#1887)
Changes:
- Updating styling of read only fields
- Removed truncation for fields and used overflow hidden instead
2025-07-10 12:35:18 +10:00
34caad2641 chore: document audit log 2025-07-07 16:10:41 +03:00
1511d2288c chore: visual changes 2025-07-07 15:28:52 +03:00
e19da93ce2 chore: template attachments 2025-07-07 13:31:55 +03:00
30b240cba2 chore: more feedback implementation 2025-07-07 12:21:07 +03:00
eb78706f35 chore: revert changes based on feedback 2025-07-07 12:04:20 +03:00
52b474d12b chore: implement feedback part 1
new form component added for document attachments with Zod validation and TRPC integration.
2025-07-04 16:29:57 +03:00
0b03bd3fce chore: remove unedeed file 2025-07-03 13:07:07 +03:00
15d0be17d7 chore: merge main 2025-07-03 12:47:37 +03:00
9917def0ca v1.12.2-rc.0 2025-07-03 10:31:22 +10:00
cdb9b9ee03 chore: add certificate error logs (#1875)
Add certificate logs
2025-07-03 10:13:12 +10:00
8d1d098e3a v1.12.1 2025-07-03 10:07:54 +10:00
b682d2785f chore: add translations (#1835)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-07-03 10:07:11 +10:00
1a1a30791e v1.12.0 2025-07-03 10:01:03 +10:00
ea1cf481eb chore: extract translations 2025-07-01 21:27:35 +10:00
eda0d5eeb6 fix: open advanced settings when fields are added to templates (#1855) 2025-07-01 21:21:13 +10:00
8da4ab533f fix(add-subject): remove superfluous word (#1866) 2025-07-01 12:34:14 +10:00
8695ef766e v1.12.0-rc.8 2025-06-30 19:47:37 +10:00
7487399123 feat: add more api logs (#1870)
Adds more detailed API logging using Pino
2025-06-30 19:46:32 +10:00
0cc729e9bd feat: add sequential document view logs (#1871)
## Description

Add a new document audit log to detect when the document is viewed. This
should only be visible in the document audit log page

Notes:
1. I wanted to reuse the `DOCUMENT_OPENED` event and add an additional
paramter to track sequential views, but it's not query-able
2. This will log "DOCUMENT_VIEWED" before "DOCUMENT_OPENED" but i don't
think it matters
2025-06-30 19:11:16 +10:00
58d97518c8 v1.12.0-rc.7 2025-06-27 22:17:45 +10:00
20c8969272 fix: get real ip for rate limit key 2025-06-27 22:17:02 +10:00
85ac65e405 v1.12.0-rc.6 2025-06-27 21:46:16 +10:00
e07a497b69 feat: api logging by pino (#1865)
experiemental
2025-06-27 21:44:51 +10:00
21dc4eee62 v1.12.0-rc.5 2025-06-27 18:53:45 +10:00
dc2042a1ee fix: rate limit api endpoints (#1863)
Rate limit API endpoint
2025-06-27 18:50:22 +10:00
338965325d chore: merge main 2025-06-24 10:49:08 +03:00
bb9ba80edb fix: duplicate fields and recipients when you duplicate a document (#1852) 2025-06-23 16:43:07 +10:00
bfe8c674f2 fix: globalAccessAuth error (#1851) 2025-06-23 10:10:57 +10:00
ebe1baf0a0 chore: extract translations 2025-06-19 15:16:44 +10:00
2345de679b feat: admin monthly active users metric (#1724) 2025-06-19 15:12:17 +10:00
1be0e2842c fix: refactor folders UI/UX (#1770)
- Add folder search
- Used correct HTML elements
- Added missing translations
- Removed automatic folder redirects
- Removed duplicate code
- Added folder loading skeletons and empty states
2025-06-19 14:57:32 +10:00
29a03d4ec7 feat: add inbox counter (#1849) 2025-06-18 13:30:01 +10:00
039cd7d449 fix: remove preconnect font links (#1798) 2025-06-18 12:42:54 +10:00
484f6c8b85 fix: admin metrics broken (#1845) 2025-06-17 21:15:11 +10:00
4fd8a767b2 chore: Update README.md (#1840) 2025-06-13 22:42:38 +10:00
b8e08e88ac fix: api keys not showing (#1839) 2025-06-13 17:20:03 +10:00
031a7b9e36 fix: visibility 2025-06-13 01:02:40 +10:00
12fe045195 fix: visiblity 2025-06-13 00:05:08 +10:00
614106a5e4 fix: rework documents limits logic (#1836) 2025-06-12 13:42:31 +10:00
8be7137b59 v1.12.0-rc.4 2025-06-12 10:27:41 +10:00
31e2a6443e fix: legacy authOptions support for api v1 2025-06-12 10:21:41 +10:00
400d2a2b1a feat: sign out of all sessions (#1797) 2025-06-11 17:57:38 +10:00
e3ce7f94e6 chore: update build 2025-06-11 14:52:23 +10:00
cad04f26e7 feat: sitemap auto-generation for docs (#1822) 2025-06-11 14:09:45 +10:00
d27f0ee0ef fix: duplicate field bugs (#1685) 2025-06-11 13:26:19 +10:00
fd2b413ed9 chore: increase wait times for tests (#1778) 2025-06-11 13:25:21 +10:00
d11ec8fa2a feat: show field coordinates in devmode (#1802)
Show the fields coordinates when the `devmode` search param is present.
It's meant to help API users understand where to position the fields.
2025-06-11 12:28:39 +10:00
b1127b4f0d chore: update readme 2025-06-11 10:42:32 +10:00
be4244fb62 chore: add translations (#1832)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-06-11 10:39:33 +10:00
504a0893ab chore: add organisation docs (#1831) 2025-06-10 20:54:36 +10:00
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
3b476e9e1f chore: merged main 2025-05-07 11:17:15 +03:00
6da56887ee chore: simplify document attachment rendering in DocumentSigningForm
- Removed unnecessary Button wrapper around attachment links.
- Enhanced layout for attachment links with improved styling and structure.
2025-05-07 11:11:36 +03:00
cec25ac719 feat: add support for attachments in template management
- Enhanced TemplateEditForm to include attachments in the template data.
- Updated createDocumentFromTemplate to handle attachment creation.
- Modified updateTemplate to manage attachment updates and deletions.
- Integrated attachments into ZTemplateSchema and ZAddTemplateSettingsFormSchema for validation.
- Improved getTemplateById to fetch attachments alongside other template data.
2025-05-06 15:48:52 +03:00
d10ec437cf fix: improve document attachment rendering logic 2025-05-05 12:50:05 +03:00
dbacfaa841 feat: enhance document attachment updates and audit logging
- Implemented detailed handling for document attachment updates in DocumentHistorySheet.
- Updated updateDocument function to log changes only when attachments differ.
- Enhanced ZDocumentSchema to include attachment type validation.
- Refined audit log formatting for document attachment updates to improve clarity.
2025-05-01 11:39:07 +03:00
6980db57d3 feat: enhance document attachment handling and audit logging
- Added support for attachment updates in the updateDocument functionc.
- Introduced new audit log type for document attachments updates.
- Updated ZDocumentAuditLog schemas to include attachment-related events.
- Modified AddSettingsFormPartial to handle attachment IDs and types correctly.
- Set default value for attachment type in the Prisma schema.
2025-04-30 15:53:58 +03:00
e3f8e76e6a feat: enhance document schema and update attachment handling
- Added attachments support to ZCreateDocumentMutationSchema and ZUpdateDocumentRequestSchema.
- Updated ZDocumentSchema to validate attachments with specific fields.
- Modified updateDocument function to handle attachment creation and deletion.
- Enhanced AddSettingsFormSchema to include attachments with proper validation.
2025-04-29 15:14:58 +03:00
396a7db587 feat: enhance document management by adding attachments support
- Updated DocumentEditForm to include attachments in the document data.
- Modified getDocumentWithDetailsById to fetch attachments.
- Updated ZDocumentSchema to validate attachments.
- Enhanced AddSettingsFormPartial to handle attachments with default values and updated field names.
2025-04-29 14:11:11 +03:00
7ac48cb3f5 chore: add template attachment management feature 2025-04-25 14:25:33 +03:00
f7ee4d0ba2 chore: merged main 2025-04-25 13:58:55 +03:00
1b67be9099 feat: add document attachments feature 2025-04-25 13:49:22 +03:00
925 changed files with 57931 additions and 78386 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

@ -105,6 +105,12 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
# [[EE ONLY]]
# OPTIONAL: The AWS SES API KEY to verify email domains with.
NEXT_PRIVATE_SES_ACCESS_KEY_ID=
NEXT_PRIVATE_SES_SECRET_ACCESS_KEY=
NEXT_PRIVATE_SES_REGION=
# [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
@ -127,4 +133,6 @@ E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[LOGGER]]
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
NEXT_PRIVATE_LOGGER_FILE_PATH=

View File

@ -1,8 +1,3 @@
---
name: Pull Request
about: Submit changes to the project for review and inclusion
---
## Description
<!--- Describe the changes introduced by this pull request. -->

7
.gitignore vendored
View File

@ -50,3 +50,10 @@ yarn-error.log*
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# logs
logs.json
# claude
.claude
CLAUDE.md

2
.npmrc
View File

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

View File

@ -49,8 +49,6 @@ Join us in creating the next generation of open trust infrastructure.
## Community and Next Steps 🎯
We're currently working on a redesign of the application, including a revamp of the codebase, so Documenso can be more intuitive to use and robust to develop upon.
- Check out the first source code release in this repository and test it.
- Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions).
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members.
@ -247,14 +245,14 @@ Now you can install the dependencies and build it:
```
npm i
npm run build:web
npm run build
npm run prisma:migrate-deploy
```
Finally, you can start it with:
```
cd apps/web
cd apps/remix
npm run start
```
@ -275,7 +273,7 @@ After=network.target
Environment=PATH=/path/to/your/node/binaries
Type=simple
User=www-data
WorkingDirectory=/var/www/documenso/apps/web
WorkingDirectory=/var/www/documenso/apps/remix
ExecStart=/usr/bin/next start -p 3500
TimeoutSec=15
Restart=always

View File

@ -34,3 +34,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# next-sitemap output
/public/sitemap.xml
/public/robots.txt
/public/sitemap-*.xml

View File

@ -0,0 +1,5 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: 'https://docs.documenso.com', // Replace with your actual site URL
generateRobotsTxt: true, // Generates robots.txt
};

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

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev -p 3002",
"build": "next build",
"build": "next build && next-sitemap",
"start": "next start -p 3002",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules"
@ -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",
@ -26,6 +26,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"next-sitemap": "^4.2.3",
"typescript": "5.6.2"
}
}
}

View File

@ -5,6 +5,7 @@
"title": "Development & Deployment"
},
"local-development": "Local Development",
"developer-mode": "Developer Mode",
"self-hosting": "Self Hosting",
"contributing": "Contributing",
"-- API & Integration Guides": {

View File

@ -0,0 +1,18 @@
---
title: Field Coordinates
description: Learn how to get the coordinates of a field in a document.
---
## Field Coordinates
Field coordinates represent the position of a field in a document. They are returned in the `pageX` and `pageY` properties of the field.
To enable field coordinates, you can use the `devmode` query parameter.
```bash
https://app.documenso.com/documents/<document-id>/edit?devmode=true
```
You should then see the coordinates on top of each field.
![Field Coordinates](/developer-mode/field-coordinates.webp)

View File

@ -1,5 +1,6 @@
{
"index": "Get Started",
"authentication": "Authentication",
"rate-limits": "Rate Limits",
"versioning": "Versioning"
}

View File

@ -33,7 +33,7 @@ Our new API V2 supports the following typed SDKs:
<Callout type="info">
For the staging API, please use the following base URL:
`https://stg-app.documenso.dev/api/v2-beta/`
`https://stg-app.documenso.com/api/v2-beta/`
</Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog)

View File

@ -0,0 +1,54 @@
import { Callout } from 'nextra/components';
# Rate Limits
Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
**Limit:** 100 requests per minute per IP address
**Response:** 429 Too Many Requests
### Rate Limit Response
```json
{
"error": "Too many requests, please try again later."
}
```
<Callout type="warning">
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying.
</Callout>
## Resource Limits
Beyond HTTP rate limits, your account has usage limits based on your subscription plan.
### Plan Limits
| Resource | Free | Paid | Self-hosted | Enterprise |
| ---------------- | ---- | --------- | ----------- | ---------- |
| Documents/month | 5 | Unlimited | Unlimited | Unlimited |
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
### Error Response
When you exceed a resource limit:
```json
{
"error": "You have reached your document limit for this month. Please upgrade your plan.",
"code": "LIMIT_EXCEEDED",
"statusCode": 400
}
```
## Error Codes
| Code | Status | Description |
| ------------------- | ------ | ----------------------------- |
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |

View File

@ -619,6 +619,18 @@ Example payload for the `document.rejected` event:
}
```
## Webhook Events Testing
You can trigger test webhook events to test the webhook functionality. To trigger a test webhook, navigate to the [Webhooks page](/developers/webhooks) and click on the "Test Webhook" button.
![Documenso's Webhooks Page](/webhook-images/test-webhooks-page.webp)
This opens a dialog where you can select the event type to test.
![Documenso's individual webhook page](/webhook-images/test-webhook-dialog.webp)
Choose the appropriate event and click "Send Test Webhook." Youll shortly receive a test payload from Documenso with sample data.
## Availability
Webhooks are available to individual users and teams.

View File

@ -6,11 +6,13 @@
"title": "How To Use"
},
"get-started": "Get Started",
"profile": "User Profile",
"signing-documents": "Signing Documents",
"profile": "Public Profile",
"organisations": "Organisations",
"documents": "Documents",
"templates": "Templates",
"branding": "Branding",
"email-domains": "Email Domains",
"direct-links": "Direct Signing Links",
"teams": "Teams",
"-- Legal Overview": {
"type": "separator",
"title": "Legal Overview"
@ -18,4 +20,4 @@
"fair-use": "Fair Use Policy",
"licenses": "Licenses",
"compliance": "Compliance"
}
}

View File

@ -0,0 +1,28 @@
---
title: Branding Preferences
description: Learn how to set the branding preferences for your team account.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Branding Preferences
Branding preferences allow you to set the default settings when emailing documents to your recipients.
## Preferences
Branding preferences can be set on either the organisation or team level.
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
To access the preferences, navigate to either the organisation or teams settings page and click the **Branding** tab under the **Preferences** section.
![A screenshot of the organisation's document preferences page](/organisations/organisation-branding.webp)
On this page, you can:
- **Upload a Logo** - Upload your team's logo to be displayed instead of the default Documenso logo.
- **Set the Brand Website** - Enter the URL of your team's website to be displayed in the email communications sent by the team.
- **Add Additional Brand Details** - You can add additional information to display at the bottom of the emails sent by the team. This can include contact information, social media links, and other relevant details.

View File

@ -0,0 +1,7 @@
{
"sending-documents": "Sending Documents",
"document-preferences": "Document Preferences",
"document-visibility": "Document Visibility",
"fields": "Document Fields",
"email-preferences": "Email Preferences"
}

View File

@ -0,0 +1,44 @@
---
title: Preferences
description: Learn how to manage your team's global preferences.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Document Preferences
Document preferences allow you to set the default settings when creating new documents and templates.
For example, you can set the default language for documents sent by the team, or set the allowed signatures types.
## Preferences
Document preferences can be set on either the organisation or team level.
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
To access the preferences, navigate to either the organisation or teams settings page and click the **Document** tab under the **Preferences** section.
![A screenshot of the organisation's document preferences page](/organisations/organisation-document-preferences.webp)
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/documents/document-visibility).
- **Default Document Language** - This setting allows you to set the default language for the documents uploaded in the organisation. The default language is used as the default language in the email communications with the document recipients.
- **Default Time Zone** - The timezone to use for date fields and signing the document.
- **Default Date Format** - The date format to use for date fields and signing the document.
- **Signature Settings** - Controls what signatures are allowed to be used when signing the documents.
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. See more below [sender details](/users/documents/document-preferences#sender-details).
- **Include the Signing Certificate** - This setting controls whether the signing certificate should be included in the signed documents. If enabled, the signing certificate is included in the signed documents. If disabled, the signing certificate is not included in the signed documents. Regardless of this setting, the signing certificate is always available in the document's audit log page.
Document visibility, language and signature settings can be overriden on a per document basis.
### Sender Details
If the **Sender Details** setting is enabled, the emails sent by the team will include the sender's name. The email will say:
> "Example User" on behalf of "Example Team" has invited you to sign "document.pdf"
If the **Sender Details** setting is disabled, the emails sent by the team will not include the sender's name. The email will say:
> "Example Team" has invited you to sign "document.pdf"

View File

@ -5,19 +5,25 @@ description: Learn how to control the visibility of your team documents.
import { Callout } from 'nextra/components';
# Team's Document Visibility
# Document Visibility
The default document visibility option allows you to control who can view and access the documents uploaded to your team account. The document visibility can be set to one of the following options:
The default document visibility option allows you to control who can view and access the documents uploaded within a team.
This value can either be set in the [document preferences](/users/documents/document-preferences), or when you [create the document](/users/documents/send-document)
## Document Visibility Options
The document visibility can be set to one of the following options:
- **Everyone** - The document is visible to all team members.
- **Managers and above** - The document is visible to team members with the role of _Manager or above_ and _Admin_.
- **Admin only** - The document is only visible to the team's admins.
![A screenshot of the document visibility selector from the team's global preferences page](/teams/team-preferences-document-visibility.webp)
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [document preferences page](/users/documents/document-preferences) and selecting a different visibility option.
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general preferences page](/users/teams/preferences) and selecting a different visibility option.
![Document visibility preference](/organisations/organisation-document-visibility.webp)
Here's how it works:
## How it works
- If a user with the "_Member_" role creates a document and the default document visibility is set to "_Everyone_", the document's visibility is set to "_EVERYONE_".
- The user can't change the visibility of the document in the document editor.

View File

@ -0,0 +1,26 @@
---
title: Email Preferences
description: Learn how to set the email preferences for your team account.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Email Preferences
Email preferences allow you to set the default settings when emailing documents to your recipients.
## Preferences
Email preferences can be set on either the organisation or team level.
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
To access the preferences, navigate to either the organisation or teams settings page and click the **Email** tab under the **Preferences** section.
![A screenshot of the organisation's email preferences page](/organisations/organisation-email-preferences.webp)
- **Default Email** - Use a custom email address when sending documents to your recipients. See [email domains](/users/email-domains) for more information.
- **Reply To** - The email address that will be used in the "Reply To" field in emails
- **Email Settings** - Which emails to send to recipients during document signing

View File

@ -115,7 +115,7 @@ All fields can be placed anywhere on the document and resized as needed.
<Callout type="info">
Learn more about the available field types and how to use them on the [Fields
page](signing-documents/fields).
page](/users/documents/fields).
</Callout>
#### Signature Required

View File

@ -0,0 +1,112 @@
import { Callout, Steps } from 'nextra/components';
# Email Domains
Email Domains allow you to send emails to recipients from your own domain instead of the default Documenso email address.
<Callout type="info">
**Platform and Enterprise Only**: Email Domains is only available to Platform and Enterprise
customers.
</Callout>
## Creating Email Domains
Before setting up email domains, ensure you have:
- A Platform or Enterprise subscription
- Access to your domain's DNS settings
- Access to your Documenso organisation as an admin or manager
<Steps>
### Access Email Domains Settings
Navigate to your Organisation email domains settings page and click the "Add Email Domain" button.
![Email Domains settings page](/email-domains/email-domains-settings-page.webp)
### Configure DNS Records
After adding your domain, Documenso will provide you with the following required DNS records that need to be configured on your domain:
- **SPF Record**: Specifies which servers are authorized to send emails from your domain
- **DKIM Record**: Provides email authentication and prevents tampering
![DNS configuration instructions](/email-domains/email-domains-record.webp)
<Callout type="info">
If you already have an SPF record configured, you will need to update it to include Amazon SES as
an authorized server instead of creating a new record.
</Callout>
Configure these records in your domain's DNS settings according to their specific instructions.
### Verify Domain Configuration
Once you've added the DNS records, return to the Documenso email domains settings page and click the "Verify" button.
This will trigger a verification process which will check if the DNS records are properly configured. If successful, the domain will be marked as "Active".
![Domain verification process](/email-domains/email-domain-sync.webp)
<Callout type="info">
Please note that it may take up to 48 hours for the DNS records to propagate.
</Callout>
</Steps>
## Creating Emails
Once your email domain has been configured, you can create multiple email addresses which your members can use when sending documents to recipients.
<Steps>
### Select the Email Domain You Want to Use
Navigate to the email domains settings page and click "Manage" on the domain you want to use.
![Email Domains settings page](/email-domains/email-domains-manage.webp)
### Add a New Email
Click on the "Add Email" button to begin the setup process.
![Create email](/email-domains/email-domains-manage-create-email.webp)
### Use Email
Once you have added an email, you can configure it to be the default email on either the:
- Organisation email preferences page
- Team email preferences page
When a draft document is created, it will inherit the email configured on the team if set, otherwise it will inherit the email configured in the organisation.
You can also configure the email address directly on the document to override the default email if required.
</Steps>
## Notes
- If you change the default email, it will not retroactively update any existing documents with the old default email.
- If the email domain becomes invalid, all emails using that domain will fail to send.
## Troubleshooting
### Common Issues
**DNS Verification Fails**
- Double-check all DNS record values
- Ensure records are added to the correct domain
- Wait for DNS propagation (up to 48 hours)
**Emails Not Delivering**
- Check domain reputation and blacklist status
- Verify SPF, DKIM, and DMARC records
- Review bounce and spam reports
<Callout type="info">
For additional support with Email Domains configuration, contact our support team at
support@documenso.com.
</Callout>

View File

@ -10,7 +10,7 @@ import { Callout, Steps } from 'nextra/components';
<Steps>
### Pick a Plan
The first step to start using Documenso is to pick a plan and create an account. At the moment of writing this guide, we have 3 plans available: Free, Individual, and Teams.
The first step to start using Documenso is to pick a plan and create an account. At the moment of writing this guide, we have 3 plans available: Free, Individual, Teams and Platform.
Explore each plan's features and choose the one that best suits your needs. The [pricing page](https://documen.so/pricing) has more information about the plans.
@ -28,6 +28,6 @@ You can claim a premium username by upgrading to a paid plan. After upgrading to
### Optional: Create a Team
If you are working with others, you can create a team and invite your team members to collaborate on your documents. More information about teams is available in the [Teams section](/users/get-started/teams).
If you are working with others, you can create a team and invite your team members to collaborate on your documents. More information about teams is available in the [Teams section](/users/organisations/teams).
</Steps>

View File

@ -1,99 +0,0 @@
---
title: Teams
description: Learn how to create and manage teams in Documenso.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Teams
Documenso allows you to create teams to collaborate with others on creating and signing documents.
<Steps>
### Create a New Team
Anyone can create a team from their account by clicking on the "+" (plus) button in the "Teams" section from the account dropdown.
![Documenso account dropdown menu](/get-started-images/add-team.webp)
Each team is a separate entity with its members, documents, and templates. You can create as many teams as you like but remember that each team is billed separately.
<Callout type="info">You can transfer the ownership of the team at any time.</Callout>
### Name and URL
Clicking the "+" button will open a modal where you must pick your team's name and URL. The URL is the team's identifier and will link to the team's page and settings. An example URL would be:
```bash
https://app.documenso.com/t/<your-team-name>
```
![Documenso create team modal](/get-started-images/add-team-2.webp)
You can select a different name and URL for your team, but we recommend using the same or similar name.
### Invite Team Members
After creating the team, you can invite team members by navigating to the "Members" tab in the team settings and clicking the "Invite member" button.
To access the team settings, click on the team's name in the account dropdown and select the appropriate team. Lastly, click again on the avatar and then "Team Settings".
Or you can copy this URL:
```bash
https://app.documenso.com/t/<your-team-name>/settings/members
```
Once you click on the "Invite member" button, you will be prompted to enter the email address of the person you want to invite. You can also select the role of the person you are inviting.
![Invite team members in Documenso dashboard](/get-started-images/add-team-members-documenso.webp)
You can also bulk-invite members by uploading a CSV file with the email addresses and roles of the people you want to invite.
The table below shows how the CSV file should be structured:
| Email address | Role |
| -------------------------- | ------- |
| team-admin@documenso.com | Admin |
| team-manager@documenso.com | Manager |
| team-member@documenso.com | Member |
<Callout type="info">
The basic team plan includes 5 members. You can invite as many members as you like by upgrading
your team's seats on the team's billing page.
</Callout>
#### Roles
You can assign different permissions to team members based on their roles. The roles available are:
| Role | Create, Edit, Send Documents | Manage Users | Manage Admins | Settings | Billing | Delete/ Transfer |
| :-----: | :--------------------------: | :----------: | :-----------: | :------: | :-----: | :--------------: |
| Member | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Manager | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ |
| Admin | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Owner | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
### Set a Team Email
You can add a team email to make signing and sending documents easier. Adding a team email allows you to:
- See a signing request sent to this email (Team Inbox)
- See all documents sent on behalf of the team
### (Optional) Transfer Team Ownership
You can transfer the team's ownership at any time. To do this, navigate to the "General" tab in the team settings and click the "Transfer team" button.
Use this URL to get to the team settings:
```bash
https://app.documenso.com/t/<your-team-name>/settings
```
### [Send your First Document](https://app.documenso.com/)
</Steps>

View File

@ -0,0 +1,7 @@
{
"index": "Introduction",
"members": "Members",
"groups": "Groups",
"teams": "Teams",
"billing": "Billing"
}

View File

@ -0,0 +1,19 @@
---
title: Billing
description: Learn how to manage your organisation's billing and subscription.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
### Billing and Subscription Management
Organisations handle billing centrally, making it easier to manage:
- **Unified Billing**: One subscription covers all teams in the organisation
- **Seat Management**: Add or remove seats across all teams automatically (Teams plan)
You can change plans, view invoices and manage your subscription from the billing page which is accessible from the organisation settings.
![A screenshot of the organisation's billing page](/organisations/organisations-billing.webp)

View File

@ -0,0 +1,75 @@
---
title: Preferences
description: Learn how to manage your team's global preferences.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Organisation Groups
Organisation groups are a powerful administrative tool that streamlines user management across your entire organisation. Instead of manually assigning individual users to multiple teams, groups allow you to manage access at scale.
This automated approach ensures consistent permissions while reducing administrative overhead for tasks like onboarding employees or managing contractor access.
## Understanding groups
### Key Benefits
- **Instant Access Management**: New hires get immediate, appropriate access across all relevant teams
- **Bulk Operations**: Remove an entire group (like a departing contractor team) and all members lose access simultaneously
- **Role Consistency**: Ensure the same role is applied consistently across teams—no more accidentally giving admin access when member access was intended
- **Audit Trail**: Easily track which groups have access to which teams
### Example use case: Legal Compliance Team
Imagine you have a legal compliance team that needs access to review documents across all departments. Instead of manually adding each legal team member to every departmental team (Sales, Marketing, HR, Operations), you can:
1. Create a "Legal Compliance" group with the "Member" Organisation Role
2. Add legal team members to this group
3. Assign the "Legal Compliance" group to the required teams
Now, when Sarah from Legal joins the company, you can simply add her to the "Legal Compliance" group. Once added, she automatically gains access to all teams the "Legal Compliance" group is assigned to.
When John from Legal leaves the company, you remove him from the group and his access is instantly revoked across all teams.
## Getting started with groups
Navigate to the "Groups" section in your organisation settings to create and manage groups.
There are two types of roles when using groups:
- **Organisation Role**: A global organisation role given to all members of the group
- **Team Role**: A team role you select when assigning the group to a team
You should generally have the "Organisation Role" set to "Organisation Member", otherwise these members would by default have access to all teams anyway due to the high organisation role.
### Creating Custom Groups
When creating a custom group, you can:
1. **Name the Group**: Give it a descriptive name that reflects its purpose
2. **Set Organisation Role**: Define the default **organisation role** for group members
3. **Add Members**: Include organisation members in the group
![Organisation group creation](/organisations/organisation-group-create.webp)
### Manage Custom Groups
By clicking the "Manage" button on a custom group, you can view all teams it is assigned to and modify the group's settings.
![Organisation group management](/organisations/organisation-group-manage.webp)
### Assigning a group to a team
To assign a group to a team, you need to navigate to the team settings and click the "Groups" tab.
![Organisation group assignment](/organisations/organisation-group-assignment.webp)
From here, click the "Add groups" button to begin the process of assigning a group to a team. Once you have added the group you can see that the members have been automatically added to the team in the members tab.
## What's next?
- [Create Your First Team](/users/organisations/teams)
- [Manage Default Settings](/users/documents/document-preferences)

View File

@ -0,0 +1,65 @@
---
title: Organisations
description: Learn how to create and manage organisations in Documenso.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Organisations
Organisations allow you to manage multiple teams and users under a single managed entity. This powerful feature enables enterprise-level collaboration and streamlined management across your entire organisation.
## What are Organisations?
Organisations are the top-level entity in Documenso's hierarchy structure:
![Organisations diagram](/organisations/organisations-basic-diagram.webp)
Each organisation can contain multiple teams, and each team can have multiple members. This structure provides:
- **Centralized Management**: Control multiple teams from a single organisational dashboard
- **Unified Billing**: Manage billing and subscriptions at the organisation level
- **Access Control**: Define roles and groups across the entire organisation
- **Group Management**: Create custom groups to organise members and control team access
- **Global Settings**: Apply consistent settings across all teams in your organisation
## Create a new organisation
You can create multiple organisations, but each organisation will be billed separately.
<Steps>
### Creating Organisations
To create a new organisation, navigate to the organisation section in your account settings and click the "Create Organisation" button.
![Create organisation in Documenso dashboard](/organisations/organisations-create.webp)
### Select your plan
Choose from our range of plans for your new organisation. If you want to instead upgrade your current organisation, you can do so by going into your settings billing page and upgrade it there.
### Name setup
When creating an organisation, you'll need to provide:
- **Organisation Name**: The display name for your organisation
</Steps>
Once your organisation is established, you can create teams to organise your work and collaborate effectively. Each team operates independently but inherits organisation-level settings and branding.
## Best Practices for Organisation Management
1. **Use groups effectively**: Leverage groups to simplify permission management
2. **Set default settings**: Configure organisation-wide settings for consistency
## What's next?
- [Create Your First Team](/users/organisations/teams)
- [Invite Organisation Members](/users/organisations/members)
- [Create Organisation Groups](/users/organisations/groups)
- [Manage Default Settings](/users/documents/document-preferences)
- [Manage Default Branding](/users/branding)

View File

@ -0,0 +1,65 @@
---
title: Members
description: Learn how to invite and manage your organisation's members.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Organisation Members
Organisation members are the core users of your organisation. They are the ones who can access the team resources and collaborate with other members.
## Organisation Roles
You can assign different permissions to organisation members by using roles. The roles available are:
| Role | Manage Settings/Teams/Members | Billing | Delete Organisation |
| :------------------: | :---------------------------: | :-----: | ------------------- |
| Organisation Owner | ✅ | ✅ | ✅ |
| Organisation Admin | ✅ | ✅ | ✅ |
| Organisation Manager | ✅ | ❌ | ❌ |
| Organisation Member | ❌ | ❌ | ❌ |
<Callout type="info">
Organisation admins and managers will automatically have access to all teams as the "Team Admin"
role. When creating a team you can also decide whether to automatically allow normal members to
access it by default as well.
</Callout>
## Invite Organisation Members
To invite organisation members, you need to be an organisation owner, admin or manager.
1. Open the menu switcher top right
2. Hover over your new organisation and click the settings icon
3. Navigate to the "Members" tab
4. Click "Invite Member"
Once you click on the "Invite member" button, you will be prompted to enter the email address of the person you want to invite. You can also select the role of the person you are inviting.
![Invite organisation members](/organisations/organisations-member-invite.webp)
You can also bulk-invite members by uploading a CSV file with the email addresses and roles of the people you want to invite.
The table below shows how the CSV file should be structured:
| Email address | Role |
| ------------------------- | ------- |
| org-admin@documenso.com | Admin |
| org-manager@documenso.com | Manager |
| org-member@documenso.com | Member |
<Callout type="info">
The basic team plan includes 5 organisation members. Going over the 5 members will charge your
organisation according to the seat plan pricing.
</Callout>
## Manage Organisation Members
On the same page, you can change the organisation member's roles or remove them from the organisation.
## What's next?
- [Use groups to organise your members](/users/organisations/groups)

View File

@ -0,0 +1,121 @@
---
title: Teams
description: Learn how to create and manage teams in Documenso.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Teams
Documenso teams allow you to collaborate with others on creating, sending and receiving documents within your organisation. Teams operate within the organisational structure and inherit settings and branding from their parent organisation.
## Team Structure
Teams provide focused collaboration spaces while benefiting from organisation-level management and settings.
Each team within an organisation has its own:
- Team members and roles
- Documents and templates
- Team-specific settings (that can override organisation defaults)
- Team email and branding (if enabled)
## Creating a Team
Only members with the "Organisation Admin" or "Organisation Manager" role can create teams.
<Steps>
### Create Team
To create a team, navigate to the organisation settings page and click the "Teams" tab. Then you can click the "Create Team" button.
![Create Team Dialog](/teams/team-create.webp)
### Name and URL
When creating a team, you'll need to provide:
- **Team Name**: The display name for your team
- **Team URL**: A unique identifier for your team
The team URL will follow this format:
```bash
https://app.documenso.com/t/<team-url>
```
You can select different names and URLs for your team, but we recommend using the same or similar names for consistency.
![Documenso create team modal](/teams/team-create-dialog.webp)
You can also decide whether to automatically inherit members from the organisation into the team. This means that all members of the organisation will have access to this team.
Members with the "Organisation Admin" or "Organisation Manager" role will be assigned as "Team Admin" regardless of this setting. This will only affect members with the "Organisation Member" role, who will be added to the team as a "Team Member".
Disabling this setting will remove all these members automatically. This can always be turned on or off later in the teams member settings page.
</Steps>
## Team Members
After creating the team, you can add organisation members into your team using two methods:
- Directly adding members to the team
- Add members using groups
### Directly adding members
1. Navigate to the team settings member page
2. Click the "Add Members" button
3. Choose which members you want to add
4. Assign the team roles for each team member
If you want to add people outside of the organisation, you will need to invite them to the organisation first.
See the [organisation members](/users/organisations/members#invite-organisation-members) page for more information.
### Adding members using groups
1. Navigate to the teams settings groups page
2. Click the "Add groups" button
3. Choose which groups you want to add
4. Assign the team roles for each group
See the [organisation groups](/users/organisations/groups) page for more information.
### Team Member Roles
You can assign different permissions to team members based on their roles. The roles available are:
| Role | Manage Documents | Manage Team | Delete Team |
| :----------: | :--------------: | :---------: | :---------: |
| Team Admin | ✅ | ✅ | ✅ |
| Team Manager | ✅ | ✅ | ❌ |
| Team Member | ✅ | ❌ | ❌ |
These roles can be used for document visibility and management as well.
## Set a Team Email
You can add a team email which allows you to:
- See signing requests sent to this email (Team Inbox)
- See documents sent from this email
- Send documents on behalf of the team
- Maintain consistent team branding in communications
## Team Settings and Branding
You can override preferences and settings from the Organisation on the Team level. See the following pages for more information:
- [Document preferences](/users/documents/document-preferences)
- [Branding preferences](/users/branding/branding-preferences)
## What's next?
- [Send your first document](/users/documents/sending-documents)
- [Setup your default document preferences](/users/documents/document-preferences)
- [Setup your default branding preferences](/users/branding)

View File

@ -1,5 +1,5 @@
---
title: User Profile
title: Public Profile
description: Learn how to set up your public profile on Documenso.
---
@ -15,7 +15,7 @@ Documenso allows you to create a public profile to share your templates for anyo
### Navigate to Your Profile Settings
Click on your profile picture in the top right corner and select "User settings". Then, navigate to the "Public Profile" tab to configure your profile.
Click on your profile picture in the top right corner and select "Settings" or "Team Settings". Then, navigate to the "Public Profile" tab to configure your profile.
![The profile settings page](/public-profile/documenso-public-profile-settings.webp)
@ -45,6 +45,8 @@ You can choose to make your profile public or private. Only you can access it if
To make your profile public, toggle the switch to the right ("Show") at the top right-hand side of the page.
![An example of a enabling a public profile on Documenso](/public-profile/documenso-enable-public-profile-settings.webp)
### (Optional) Link Templates
Linking templates to your profile is optional, but it's what makes your profile helpful. Linking templates allow people to sign documents directly from your profile. As a result, we recommend linking at least one template you want to share with others.

View File

@ -1,4 +0,0 @@
{
"index": "Send Documents",
"fields": "Document Fields"
}

View File

@ -1,6 +0,0 @@
{
"preferences": "Preferences",
"document-visibility": "Document Visibility",
"sender-details": "Email Sender Details",
"branding-preferences": "Branding Preferences"
}

View File

@ -1,16 +0,0 @@
---
title: Branding Preferences
description: Learn how to set the branding preferences for your team account.
---
# Branding Preferences
You can set the branding preferences for your team account by going to the **Branding Preferences** tab in the team's settings dashboard.
![A screenshot of the team's branding preferences page](/teams/team-branding-preferences.webp)
On this page, you can:
- **Upload a Logo** - Upload your team's logo to be displayed instead of the default Documenso logo.
- **Set the Brand Website** - Enter the URL of your team's website to be displayed in the email communications sent by the team.
- **Add Additional Brand Details** - You can add additional information to display at the bottom of the emails sent by the team. This can include contact information, social media links, and other relevant details.

View File

@ -1,19 +0,0 @@
---
title: Preferences
description: Learn how to manage your team's global preferences.
---
# Preferences
You can manage your team's global preferences by clicking on the **Preferences** tab in the team's settings dashboard.
![A screenshot of the team's global preferences page](/teams/team-preferences.webp)
The preferences page allows you to update the following settings:
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility).
- **Default Document Language** - This setting allows you to set the default language for the documents uploaded in the team account. The default language is used as the default language in the email communications with the document recipients. You can change the language for individual documents when uploading them.
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details).
- **Typed Signature** - It controls whether the document recipients can sign the documents with a typed signature or not. If enabled, the recipients can sign the document using either a drawn or a typed signature. If disabled, the recipients can only sign the documents usign a drawn signature. This setting can also be changed for individual documents when uploading them.
- **Include the Signing Certificate** - This setting controls whether the signing certificate should be included in the signed documents. If enabled, the signing certificate is included in the signed documents. If disabled, the signing certificate is not included in the signed documents. Regardless of this setting, the signing certificate is always available in the document's audit log page.
- **Branding Preferences** - Set the branding preferences and defaults for the team account. Learn more about [branding preferences](/users/teams/branding-preferences).

View File

@ -1,14 +0,0 @@
---
title: Email Sender Details
description: Learn how to update the sender details for your team's email notifications.
---
## Sender Details
If the **Sender Details** setting is enabled, the emails sent by the team will include the sender's name. The email will say:
> "Example User" on behalf of "Example Team" has invited you to sign "document.pdf"
If the **Sender Details** setting is disabled, the emails sent by the team will not include the sender's name. The email will say:
> "Example Team" has invited you to sign "document.pdf"

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

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

@ -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,19 +1,30 @@
@import '@documenso/ui/styles/theme.css';
/* Inter Variable Fonts */
@font-face {
font-family: 'Inter';
src: url('/public/fonts/inter-regular.ttf') format('ttf');
/* font-weight: 400;
src: url('/fonts/inter-variablefont_opsz,wght.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap; */
font-display: swap;
}
/* Inter Italic Variable Fonts */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-italic-variablefont_opsz,wght.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
/* Caveat Variable Font */
@font-face {
font-family: 'Caveat';
src: url('/public/fonts/caveat.ttf') format('ttf');
/* font-weight: 400;
src: url('/fonts/caveat-variablefont_wght.ttf') format('truetype-variations');
font-weight: 400 600;
font-style: normal;
font-display: swap; */
font-display: swap;
}
@layer base {

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

@ -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

@ -1,11 +1,11 @@
import { useEffect } from 'react';
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 type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
@ -31,9 +31,10 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
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 DocumentMoveToFolderDialogProps = {
documentId: number;
@ -57,8 +58,11 @@ export const DocumentMoveToFolderDialog = ({
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
@ -82,6 +86,7 @@ export const DocumentMoveToFolderDialog = ({
useEffect(() => {
if (!open) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
@ -94,6 +99,14 @@ export const DocumentMoveToFolderDialog = ({
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.`),
@ -101,14 +114,6 @@ export const DocumentMoveToFolderDialog = ({
});
onOpenChange(false);
const documentsPath = formatDocumentsPath(team?.url);
if (data.folderId) {
void navigate(`${documentsPath}/f/${data.folderId}`);
} else {
void navigate(documentsPath);
}
} catch (err) {
const error = AppError.parseError(err);
@ -122,6 +127,16 @@ export const DocumentMoveToFolderDialog = ({
return;
}
if (error.code === AppErrorCode.UNAUTHORIZED) {
toast({
title: _(msg`Error`),
description: _(msg`You are not allowed to move this document.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the document.`),
@ -130,6 +145,10 @@ export const DocumentMoveToFolderDialog = ({
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
@ -143,8 +162,18 @@ export const DocumentMoveToFolderDialog = ({
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
@ -153,8 +182,9 @@ export const DocumentMoveToFolderDialog = ({
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
@ -169,10 +199,10 @@ export const DocumentMoveToFolderDialog = ({
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
<Trans>Home (No Folder)</Trans>
</Button>
{folders?.data.map((folder) => (
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
@ -185,6 +215,12 @@ export const DocumentMoveToFolderDialog = ({
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>

View File

@ -36,7 +36,7 @@ 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';
@ -57,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

@ -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 { Trans, useLingui } from '@lingui/react/macro';
import type { FolderType } from '@prisma/client';
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 { 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 {
@ -34,26 +31,22 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } 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 = {
export type FolderCreateDialogProps = {
type: FolderType;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
const { _ } = useLingui();
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
@ -67,37 +60,21 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.DOCUMENT,
type,
});
setIsCreateFolderOpen(false);
toast({
description: 'Folder created successfully',
description: t`Folder created successfully`,
});
const documentsPath = formatDocumentsPath(team?.url);
void navigate(`${documentsPath}/f/${newFolder.id}`);
} 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.`),
title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`,
variant: 'destructive',
});
}
@ -113,48 +90,60 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp
<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
variant="outline"
className="flex items-center"
data-testid="folder-create-button"
>
<FolderPlusIcon className="mr-2 h-4 w-4" />
<Trans>Create Folder</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogTitle>
<Trans>Create New Folder</Trans>
</DialogTitle>
<DialogDescription>
Enter a name for your new folder. Folders help you organize your documents.
<Trans>Enter a name for your new folder. Folders help you organise your items.</Trans>
</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>
)}
/>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder Name</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`My Folder`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>

View File

@ -1,8 +1,7 @@
import { useEffect } 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 * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
@ -11,6 +10,7 @@ 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 { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -32,22 +32,22 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
folder: TFolderWithSubfolders;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const deleteMessage = t`delete ${folder.name}`;
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
}),
});
@ -61,8 +61,6 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
@ -71,15 +69,15 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
title: t`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.`),
title: t`Folder not found`,
description: t`The folder you are trying to delete does not exist.`,
variant: 'destructive',
});
@ -87,8 +85,8 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
title: t`Failed to delete folder`,
description: t`An unknown error occurred while deleting the folder.`,
variant: 'destructive',
});
}
@ -104,53 +102,65 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Folder</DialogTitle>
<DialogTitle>
<Trans>Delete Folder</Trans>
</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>
)}
<Trans>Are you sure you want to delete this folder?</Trans>
</DialogDescription>
</DialogHeader>
{(folder._count.documents > 0 ||
folder._count.templates > 0 ||
folder._count.subfolders > 0) && (
<Alert variant="destructive">
<AlertDescription>
<Trans>
This folder contains multiple items. Deleting it will remove all subfolders and move
all nested documents and templates to the root folder.
</Trans>
</AlertDescription>
</Alert>
)}
<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 onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} 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 placeholder={deleteMessage} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="destructive"
type="submit"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>

View File

@ -1,10 +1,10 @@
import { useEffect } from 'react';
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 * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { FolderIcon, HomeIcon, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -27,6 +27,7 @@ import {
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderMoveDialogProps = {
@ -48,9 +49,10 @@ export const FolderMoveDialog = ({
isOpen,
onOpenChange,
}: FolderMoveDialogProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
@ -72,15 +74,15 @@ export const FolderMoveDialog = ({
onOpenChange(false);
toast({
title: 'Folder moved successfully',
title: t`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.`),
title: t`Folder not found`,
description: t`The folder you are trying to move does not exist.`,
variant: 'destructive',
});
@ -88,8 +90,8 @@ export const FolderMoveDialog = ({
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
title: t`Failed to move folder`,
description: t`An unknown error occurred while moving the folder.`,
variant: 'destructive',
});
}
@ -98,69 +100,91 @@ export const FolderMoveDialog = ({
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
// Filter out the current folder, only show folders of the same type, and filter by search term
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
(f) =>
f.id !== folder?.id &&
f.type === folder?.type &&
(searchTerm === '' || f.name.toLowerCase().includes(searchTerm.toLowerCase())),
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
<DialogTitle>
<Trans>Move Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a destination for this folder.</Trans>
</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>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
<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" />
<Trans>Home</Trans>
</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)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>

View File

@ -1,8 +1,8 @@
import { useEffect } 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 * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -14,6 +14,7 @@ import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@ -40,7 +41,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderSettingsDialogProps = {
export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@ -53,12 +54,8 @@ export const ZUpdateFolderFormSchema = z.object({
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderSettingsDialog = ({
folder,
isOpen,
onOpenChange,
}: FolderSettingsDialogProps) => {
const { _ } = useLingui();
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
@ -84,7 +81,9 @@ export const FolderSettingsDialog = ({
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) return;
if (!folder) {
return;
}
try {
await updateFolder({
@ -96,7 +95,7 @@ export const FolderSettingsDialog = ({
});
toast({
title: _(msg`Folder updated successfully`),
title: t`Folder updated successfully`,
});
onOpenChange(false);
@ -105,7 +104,7 @@ export const FolderSettingsDialog = ({
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Folder not found`),
title: t`Folder not found`,
});
}
}
@ -115,8 +114,12 @@ export const FolderSettingsDialog = ({
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Folder Settings</DialogTitle>
<DialogDescription>Manage the settings for this folder.</DialogDescription>
<DialogTitle>
<Trans>Folder Settings</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Manage the settings for this folder.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
@ -126,7 +129,9 @@ export const FolderSettingsDialog = ({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -141,19 +146,25 @@ export const FolderSettingsDialog = ({
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<FormLabel>
<Trans>Visibility</Trans>
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
<SelectValue placeholder={t`Select visibility`} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
Managers and above
<SelectItem value={DocumentVisibility.EVERYONE}>
<Trans>Everyone</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<Trans>Managers and above</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>
<Trans>Admins only</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@ -163,7 +174,15 @@ export const FolderSettingsDialog = ({
)}
<DialogFooter>
<Button type="submit">Save Changes</Button>
<DialogClose asChild>
<Button variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</form>
</Form>

View File

@ -0,0 +1,446 @@
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 { isPersonalLayout } from '@documenso/lib/utils/organisations';
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';
import { IndividualPersonalLayoutCheckoutButton } from '../general/billing-plans';
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, organisations } = useSession();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const isPersonalLayoutMode = isPersonalLayout(organisations);
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.enterprise.billing.plans.get.useQuery(undefined, {
enabled: IS_BILLING_ENABLED(),
});
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]);
const isIndividualPlan = (priceId: string) => {
return (
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.yearlyPrice?.id === priceId
);
};
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>
{isIndividualPlan(selectedPriceId) && isPersonalLayoutMode ? (
<IndividualPersonalLayoutCheckoutButton priceId={selectedPriceId}>
<Trans>Checkout</Trans>
</IndividualPersonalLayoutCheckoutButton>
) : (
<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) =>
// Purposely using the opposite billing period to get the correct plan.
plan[billingPeriod === 'monthlyPrice' ? 'yearlyPrice' : 'monthlyPrice']?.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,243 @@
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 type { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email.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 { useToast } from '@documenso/ui/primitives/use-toast';
type EmailDomain = {
id: string;
domain: string;
status: string;
};
export type OrganisationEmailCreateDialogProps = {
trigger?: React.ReactNode;
emailDomain: EmailDomain;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateOrganisationEmailFormSchema = ZCreateOrganisationEmailRequestSchema.pick({
emailName: true,
email: true,
// replyTo: true,
});
type TCreateOrganisationEmailFormSchema = z.infer<typeof ZCreateOrganisationEmailFormSchema>;
export const OrganisationEmailCreateDialog = ({
trigger,
emailDomain,
...props
}: OrganisationEmailCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(ZCreateOrganisationEmailFormSchema),
defaultValues: {
emailName: '',
email: '',
// replyTo: '',
},
});
const { mutateAsync: createOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
try {
await createOrganisationEmail({
emailDomainId: emailDomain.id,
...data,
});
toast({
title: t`Email Created`,
description: t`The organisation email has been created successfully.`,
});
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: t`Email already exists`,
description: t`An email with this address already exists.`,
variant: 'destructive',
});
} else {
toast({
title: t`An error occurred`,
description: t`We encountered an error while creating the email. Please try again later.`,
variant: 'destructive',
});
}
}
};
return (
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Add Email</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>Add Organisation Email</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Create a new email address for your organisation using the domain{' '}
<span className="font-bold">{emailDomain.domain}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
<FormField
control={form.control}
name="emailName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Display Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="Support" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>The display name for this email address</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<div className="relative flex items-center gap-2">
<Input
{...field}
value={field.value.split('@')[0]}
onChange={(e) => {
field.onChange(e.target.value + '@' + emailDomain.domain);
}}
placeholder="support"
/>
<div className="bg-muted text-muted-foreground absolute bottom-0 right-0 top-0 flex items-center rounded-r-md border px-3 py-2 text-sm">
@{emailDomain.domain}
</div>
</div>
</FormControl>
<FormMessage />
{!form.formState.errors.email && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
field.value
) : (
<Trans>
The part before the @ symbol (e.g., "support" for support@
{emailDomain.domain})
</Trans>
)}
</span>
)}
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="replyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply-To Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>
Optional no-reply email address attached to emails. Leave blank to default
to the organisation settings reply-to email.
</Trans>
</FormDescription>
</FormItem>
)}
/> */}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-email-button"
loading={isPending}
>
<Trans>Create Email</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,111 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
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 OrganisationEmailDeleteDialogProps = {
emailId: string;
email: string;
trigger?: React.ReactNode;
};
export const OrganisationEmailDeleteDialog = ({
trigger,
emailId,
email,
}: OrganisationEmailDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: deleteEmail, isPending: isDeleting } =
trpc.enterprise.organisation.email.delete.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully removed this email from the organisation.`,
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to remove this email. 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 email</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 email from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">{email}</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 () =>
deleteEmail({
emailId,
})
}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,199 @@
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 type { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationEmailDomainRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.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 { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationEmailDomainRecordContent } from './organisation-email-domain-records-dialog';
export type OrganisationEmailCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateOrganisationEmailDomainFormSchema = ZCreateOrganisationEmailDomainRequestSchema.pick({
domain: true,
});
type TCreateOrganisationEmailDomainFormSchema = z.infer<
typeof ZCreateOrganisationEmailDomainFormSchema
>;
type DomainRecord = {
name: string;
value: string;
type: string;
};
export const OrganisationEmailDomainCreateDialog = ({
trigger,
...props
}: OrganisationEmailCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'domain' | 'verification'>('domain');
const [recordsToAdd, setRecordsToAdd] = useState<DomainRecord[]>([]);
const form = useForm({
resolver: zodResolver(ZCreateOrganisationEmailDomainFormSchema),
defaultValues: {
domain: '',
},
});
const { mutateAsync: createOrganisationEmail } =
trpc.enterprise.organisation.emailDomain.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
setStep('domain');
}
}, [open, form]);
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
try {
const { records } = await createOrganisationEmail({
domain,
organisationId: organisation.id,
});
setRecordsToAdd(records);
setStep('verification');
toast({
title: t`Domain Added`,
description: t`DKIM records generated. Please add the DNS records to verify your domain.`,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: t`Domain already in use`,
description: t`Please try a different domain.`,
variant: 'destructive',
duration: 10000,
});
} else {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add your domain. Please try again later.`,
variant: 'destructive',
});
}
}
};
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>Add Email Domain</Trans>
</Button>
)}
</DialogTrigger>
{step === 'domain' ? (
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>Add Custom Email Domain</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Add a custom domain to send emails on behalf of your organisation. We'll generate
DKIM records that you need to add to your DNS provider.
</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="domain"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Domain Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="example.com" className="bg-background" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>
Enter the domain you want to use for sending emails (without http:// or
www)
</Trans>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-email-button"
loading={form.formState.isSubmitting}
>
<Trans>Generate DKIM Records</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
) : (
<OrganisationEmailDomainRecordContent records={recordsToAdd} />
)}
</Dialog>
);
};

View File

@ -0,0 +1,161 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
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 OrganisationEmailDomainDeleteDialogProps = {
emailDomainId: string;
emailDomain: string;
trigger?: React.ReactNode;
};
export const OrganisationEmailDomainDeleteDialog = ({
trigger,
emailDomainId,
emailDomain,
}: OrganisationEmailDomainDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const deleteMessage = t`delete ${emailDomain}`;
const ZDeleteEmailDomainFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
}),
});
const form = useForm<z.infer<typeof ZDeleteEmailDomainFormSchema>>({
resolver: zodResolver(ZDeleteEmailDomainFormSchema),
defaultValues: {
confirmText: '',
},
});
const { mutateAsync: deleteEmailDomain, isPending: isDeleting } =
trpc.enterprise.organisation.emailDomain.delete.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully removed this email domain from the organisation.`,
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to remove this email domain. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
const onFormSubmit = async () => {
await deleteEmailDomain({
emailDomainId,
});
};
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete email domain</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 email domain{' '}
<span className="font-semibold">{emailDomain}</span> from{' '}
<span className="font-semibold">{organisation.name}</span>. All emails associated with
this domain will be deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} 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 placeholder={deleteMessage} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="destructive"
type="submit"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,139 @@
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationEmailDomainRecordsDialogProps = {
trigger: React.ReactNode;
records: DomainRecord[];
} & Omit<DialogPrimitive.DialogProps, 'children'>;
type DomainRecord = {
name: string;
value: string;
type: string;
};
export const OrganisationEmailDomainRecordsDialog = ({
trigger,
records,
...props
}: OrganisationEmailDomainRecordsDialogProps) => {
return (
<Dialog {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger}
</DialogTrigger>
<OrganisationEmailDomainRecordContent records={records} />
</Dialog>
);
};
export const OrganisationEmailDomainRecordContent = ({ records }: { records: DomainRecord[] }) => {
const { t } = useLingui();
const { toast } = useToast();
return (
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>Verify Domain</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Add these DNS records to verify your domain ownership</Trans>
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-4">
{records.map((record) => (
<div className="space-y-4 rounded-md border p-4" key={record.name}>
<div className="space-y-2">
<Label>
<Trans>Record Type</Trans>
</Label>
<div className="relative">
<Input className="pr-12" disabled value={record.type} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={record.type}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label>
<Trans>Record Name</Trans>
</Label>
<div className="relative">
<Input className="pr-12" disabled value={record.name} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={record.name}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label>
<Trans>Record Value</Trans>
</Label>
<div className="relative">
<Input className="pr-12" disabled value={record.value} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={record.value}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
</div>
</div>
))}
</div>
<Alert variant="neutral">
<AlertDescription>
<Trans>
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
Once the DNS propagation is complete you will need to come back and press the "Sync"
domains button
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>
</div>
</DialogContent>
);
};

View File

@ -0,0 +1,184 @@
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 type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
import { ZUpdateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-email.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 { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationEmailUpdateDialogProps = {
trigger: React.ReactNode;
organisationEmail: TGetOrganisationEmailDomainResponse['emails'][number];
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateOrganisationEmailFormSchema = ZUpdateOrganisationEmailRequestSchema.pick({
emailName: true,
// replyTo: true,
});
type ZUpdateOrganisationEmailSchema = z.infer<typeof ZUpdateOrganisationEmailFormSchema>;
export const OrganisationEmailUpdateDialog = ({
trigger,
organisationEmail,
...props
}: OrganisationEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const form = useForm<ZUpdateOrganisationEmailSchema>({
resolver: zodResolver(ZUpdateOrganisationEmailFormSchema),
defaultValues: {
emailName: organisationEmail.emailName,
// replyTo: organisationEmail.replyTo ?? undefined,
},
});
const { mutateAsync: updateOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.update.useMutation();
const onFormSubmit = async ({ emailName }: ZUpdateOrganisationEmailSchema) => {
try {
await updateOrganisationEmail({
emailId: organisationEmail.id,
emailName,
// replyTo,
});
toast({
title: t`Success`,
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: t`An unknown error occurred`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset({
emailName: organisationEmail.emailName,
// replyTo: organisationEmail.replyTo ?? undefined,
});
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update email</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationEmail.email}</span>
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
<FormField
control={form.control}
name="emailName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Display Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="Support" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>The display name for this email address</Trans>
</FormDescription>
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="replyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply-To Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>
Optional no-reply email address attached to emails. Leave blank to default
to the organisation settings reply-to email.
</Trans>
</FormDescription>
</FormItem>
)}
/> */}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Update</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, SUPPORT_EMAIL } 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_EMAIL}`}>{SUPPORT_EMAIL}</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);

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