diff --git a/.cursorrules b/.cursorrules index 61a306ff0..a40eec787 100644 --- a/.cursorrules +++ b/.cursorrules @@ -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
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 \ No newline at end of file +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 string 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 diff --git a/README.md b/README.md index d7f79adda..f44b88c2a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/documentation/.gitignore b/apps/documentation/.gitignore index fd3dbb571..35bcb7373 100644 --- a/apps/documentation/.gitignore +++ b/apps/documentation/.gitignore @@ -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 diff --git a/apps/documentation/next-sitemap.config.js b/apps/documentation/next-sitemap.config.js new file mode 100644 index 000000000..53571e850 --- /dev/null +++ b/apps/documentation/next-sitemap.config.js @@ -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 +}; diff --git a/apps/documentation/package.json b/apps/documentation/package.json index c18307c5d..6408e156b 100644 --- a/apps/documentation/package.json +++ b/apps/documentation/package.json @@ -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" @@ -26,6 +26,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "next-sitemap": "^4.2.3", "typescript": "5.6.2" } -} \ No newline at end of file +} diff --git a/apps/documentation/pages/developers/_meta.json b/apps/documentation/pages/developers/_meta.json index 0057496b3..b3238257d 100644 --- a/apps/documentation/pages/developers/_meta.json +++ b/apps/documentation/pages/developers/_meta.json @@ -5,6 +5,7 @@ "title": "Development & Deployment" }, "local-development": "Local Development", + "developer-mode": "Developer Mode", "self-hosting": "Self Hosting", "contributing": "Contributing", "-- API & Integration Guides": { diff --git a/apps/documentation/pages/developers/developer-mode/field-coordinates.mdx b/apps/documentation/pages/developers/developer-mode/field-coordinates.mdx new file mode 100644 index 000000000..7489912e6 --- /dev/null +++ b/apps/documentation/pages/developers/developer-mode/field-coordinates.mdx @@ -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//edit?devmode=true +``` + +You should then see the coordinates on top of each field. + +![Field Coordinates](/developer-mode/field-coordinates.webp) diff --git a/apps/documentation/pages/users/_meta.json b/apps/documentation/pages/users/_meta.json index 335b80eb4..06334a4d8 100644 --- a/apps/documentation/pages/users/_meta.json +++ b/apps/documentation/pages/users/_meta.json @@ -6,11 +6,12 @@ "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", "direct-links": "Direct Signing Links", - "teams": "Teams", "-- Legal Overview": { "type": "separator", "title": "Legal Overview" @@ -18,4 +19,4 @@ "fair-use": "Fair Use Policy", "licenses": "Licenses", "compliance": "Compliance" -} +} \ No newline at end of file diff --git a/apps/documentation/pages/users/branding.mdx b/apps/documentation/pages/users/branding.mdx new file mode 100644 index 000000000..8fb9412cb --- /dev/null +++ b/apps/documentation/pages/users/branding.mdx @@ -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 **Preferences** tab. This page contains both the preferences for documents and branding, the branding section is located at the bottom of the page. + +![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. diff --git a/apps/documentation/pages/users/documents/_meta.json b/apps/documentation/pages/users/documents/_meta.json new file mode 100644 index 000000000..0705deb93 --- /dev/null +++ b/apps/documentation/pages/users/documents/_meta.json @@ -0,0 +1,5 @@ +{ + "sending-documents": "Sending Documents", + "document-preferences": "Document Preferences", + "document-visibility": "Document Visibility" +} \ No newline at end of file diff --git a/apps/documentation/pages/users/documents/document-preferences.mdx b/apps/documentation/pages/users/documents/document-preferences.mdx new file mode 100644 index 000000000..18b26f44b --- /dev/null +++ b/apps/documentation/pages/users/documents/document-preferences.mdx @@ -0,0 +1,42 @@ +--- +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 **Preferences** tab. + +![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. +- **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" diff --git a/apps/documentation/pages/users/teams/document-visibility.mdx b/apps/documentation/pages/users/documents/document-visibility.mdx similarity index 83% rename from apps/documentation/pages/users/teams/document-visibility.mdx rename to apps/documentation/pages/users/documents/document-visibility.mdx index 818c51b47..92530feb3 100644 --- a/apps/documentation/pages/users/teams/document-visibility.mdx +++ b/apps/documentation/pages/users/documents/document-visibility.mdx @@ -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. diff --git a/apps/documentation/pages/users/signing-documents/fields.mdx b/apps/documentation/pages/users/documents/fields.mdx similarity index 100% rename from apps/documentation/pages/users/signing-documents/fields.mdx rename to apps/documentation/pages/users/documents/fields.mdx diff --git a/apps/documentation/pages/users/signing-documents/index.mdx b/apps/documentation/pages/users/documents/sending-documents.mdx similarity index 99% rename from apps/documentation/pages/users/signing-documents/index.mdx rename to apps/documentation/pages/users/documents/sending-documents.mdx index f37afc8f7..19d012bc3 100644 --- a/apps/documentation/pages/users/signing-documents/index.mdx +++ b/apps/documentation/pages/users/documents/sending-documents.mdx @@ -115,7 +115,7 @@ All fields can be placed anywhere on the document and resized as needed. Learn more about the available field types and how to use them on the [Fields - page](signing-documents/fields). + page](/users/documents/fields). #### Signature Required diff --git a/apps/documentation/pages/users/get-started/account-creation.mdx b/apps/documentation/pages/users/get-started/account-creation.mdx index bf1f67983..bea55e060 100644 --- a/apps/documentation/pages/users/get-started/account-creation.mdx +++ b/apps/documentation/pages/users/get-started/account-creation.mdx @@ -10,7 +10,7 @@ import { Callout, Steps } from 'nextra/components'; ### 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). diff --git a/apps/documentation/pages/users/get-started/teams.mdx b/apps/documentation/pages/users/get-started/teams.mdx deleted file mode 100644 index bfb4e6b8c..000000000 --- a/apps/documentation/pages/users/get-started/teams.mdx +++ /dev/null @@ -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. - - - -### 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. - -You can transfer the ownership of the team at any time. - -### 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/ -``` - -![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//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 | - - - 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. - - -#### 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//settings -``` - -### [Send your First Document](https://app.documenso.com/) - - diff --git a/apps/documentation/pages/users/organisations/_meta.json b/apps/documentation/pages/users/organisations/_meta.json new file mode 100644 index 000000000..b77f9137c --- /dev/null +++ b/apps/documentation/pages/users/organisations/_meta.json @@ -0,0 +1,7 @@ +{ + "index": "Introduction", + "members": "Members", + "groups": "Groups", + "teams": "Teams", + "billing": "Billing" +} \ No newline at end of file diff --git a/apps/documentation/pages/users/organisations/billing.mdx b/apps/documentation/pages/users/organisations/billing.mdx new file mode 100644 index 000000000..571e9408c --- /dev/null +++ b/apps/documentation/pages/users/organisations/billing.mdx @@ -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) diff --git a/apps/documentation/pages/users/organisations/groups.mdx b/apps/documentation/pages/users/organisations/groups.mdx new file mode 100644 index 000000000..6c2148d55 --- /dev/null +++ b/apps/documentation/pages/users/organisations/groups.mdx @@ -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) diff --git a/apps/documentation/pages/users/organisations/index.mdx b/apps/documentation/pages/users/organisations/index.mdx new file mode 100644 index 000000000..0c094e44b --- /dev/null +++ b/apps/documentation/pages/users/organisations/index.mdx @@ -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. + + + +### 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 + + + +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) diff --git a/apps/documentation/pages/users/organisations/members.mdx b/apps/documentation/pages/users/organisations/members.mdx new file mode 100644 index 000000000..e66212b84 --- /dev/null +++ b/apps/documentation/pages/users/organisations/members.mdx @@ -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 | ❌ | ❌ | ❌ | + + + 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. + + +## 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 | + + + The basic team plan includes 5 organisation members. Going over the 5 members will charge your + organisation according to the seat plan pricing. + + +## 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) diff --git a/apps/documentation/pages/users/organisations/teams.mdx b/apps/documentation/pages/users/organisations/teams.mdx new file mode 100644 index 000000000..9b9e63675 --- /dev/null +++ b/apps/documentation/pages/users/organisations/teams.mdx @@ -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. + + + +### 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/ +``` + +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. + + + +## 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) diff --git a/apps/documentation/pages/users/profile.mdx b/apps/documentation/pages/users/profile.mdx index 4b7e5b1ff..780c226c8 100644 --- a/apps/documentation/pages/users/profile.mdx +++ b/apps/documentation/pages/users/profile.mdx @@ -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. diff --git a/apps/documentation/pages/users/signing-documents/_meta.json b/apps/documentation/pages/users/signing-documents/_meta.json deleted file mode 100644 index 6fdc8b23e..000000000 --- a/apps/documentation/pages/users/signing-documents/_meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "index": "Send Documents", - "fields": "Document Fields" -} diff --git a/apps/documentation/pages/users/teams/_meta.json b/apps/documentation/pages/users/teams/_meta.json deleted file mode 100644 index fc849e49d..000000000 --- a/apps/documentation/pages/users/teams/_meta.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "preferences": "Preferences", - "document-visibility": "Document Visibility", - "sender-details": "Email Sender Details", - "branding-preferences": "Branding Preferences" -} diff --git a/apps/documentation/pages/users/teams/branding-preferences.mdx b/apps/documentation/pages/users/teams/branding-preferences.mdx deleted file mode 100644 index 15e074af7..000000000 --- a/apps/documentation/pages/users/teams/branding-preferences.mdx +++ /dev/null @@ -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. diff --git a/apps/documentation/pages/users/teams/preferences.mdx b/apps/documentation/pages/users/teams/preferences.mdx deleted file mode 100644 index de0b7ca39..000000000 --- a/apps/documentation/pages/users/teams/preferences.mdx +++ /dev/null @@ -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). diff --git a/apps/documentation/pages/users/teams/sender-details.mdx b/apps/documentation/pages/users/teams/sender-details.mdx deleted file mode 100644 index 196cd22e7..000000000 --- a/apps/documentation/pages/users/teams/sender-details.mdx +++ /dev/null @@ -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" diff --git a/apps/documentation/public/developer-mode/field-coordinates.webp b/apps/documentation/public/developer-mode/field-coordinates.webp new file mode 100644 index 000000000..2089e8b15 Binary files /dev/null and b/apps/documentation/public/developer-mode/field-coordinates.webp differ diff --git a/apps/documentation/public/direct-links/documenso-profile.webp b/apps/documentation/public/direct-links/documenso-profile.webp index d0746f345..5a935a464 100644 Binary files a/apps/documentation/public/direct-links/documenso-profile.webp and b/apps/documentation/public/direct-links/documenso-profile.webp differ diff --git a/apps/documentation/public/get-started-images/add-team-2.webp b/apps/documentation/public/get-started-images/add-team-2.webp deleted file mode 100644 index 84260ab79..000000000 Binary files a/apps/documentation/public/get-started-images/add-team-2.webp and /dev/null differ diff --git a/apps/documentation/public/get-started-images/add-team-members-documenso.webp b/apps/documentation/public/get-started-images/add-team-members-documenso.webp deleted file mode 100644 index bd485d82c..000000000 Binary files a/apps/documentation/public/get-started-images/add-team-members-documenso.webp and /dev/null differ diff --git a/apps/documentation/public/get-started-images/add-team.webp b/apps/documentation/public/get-started-images/add-team.webp deleted file mode 100644 index 799ba3f81..000000000 Binary files a/apps/documentation/public/get-started-images/add-team.webp and /dev/null differ diff --git a/apps/documentation/public/organisations/organisation-branding.webp b/apps/documentation/public/organisations/organisation-branding.webp new file mode 100644 index 000000000..dee0d7c32 Binary files /dev/null and b/apps/documentation/public/organisations/organisation-branding.webp differ diff --git a/apps/documentation/public/organisations/organisation-document-preferences.webp b/apps/documentation/public/organisations/organisation-document-preferences.webp new file mode 100644 index 000000000..4aa01b4a5 Binary files /dev/null and b/apps/documentation/public/organisations/organisation-document-preferences.webp differ diff --git a/apps/documentation/public/organisations/organisation-document-visibility.webp b/apps/documentation/public/organisations/organisation-document-visibility.webp new file mode 100644 index 000000000..054827e8e Binary files /dev/null and b/apps/documentation/public/organisations/organisation-document-visibility.webp differ diff --git a/apps/documentation/public/organisations/organisation-group-assignment.webp b/apps/documentation/public/organisations/organisation-group-assignment.webp new file mode 100644 index 000000000..2ead5a1df Binary files /dev/null and b/apps/documentation/public/organisations/organisation-group-assignment.webp differ diff --git a/apps/documentation/public/organisations/organisation-group-create.webp b/apps/documentation/public/organisations/organisation-group-create.webp new file mode 100644 index 000000000..79f98a98d Binary files /dev/null and b/apps/documentation/public/organisations/organisation-group-create.webp differ diff --git a/apps/documentation/public/organisations/organisation-group-manage.webp b/apps/documentation/public/organisations/organisation-group-manage.webp new file mode 100644 index 000000000..e907e423d Binary files /dev/null and b/apps/documentation/public/organisations/organisation-group-manage.webp differ diff --git a/apps/documentation/public/organisations/organisations-basic-diagram.webp b/apps/documentation/public/organisations/organisations-basic-diagram.webp new file mode 100644 index 000000000..7f1a3da8b Binary files /dev/null and b/apps/documentation/public/organisations/organisations-basic-diagram.webp differ diff --git a/apps/documentation/public/organisations/organisations-billing.webp b/apps/documentation/public/organisations/organisations-billing.webp new file mode 100644 index 000000000..03e2cb15f Binary files /dev/null and b/apps/documentation/public/organisations/organisations-billing.webp differ diff --git a/apps/documentation/public/organisations/organisations-create.webp b/apps/documentation/public/organisations/organisations-create.webp new file mode 100644 index 000000000..f3c036365 Binary files /dev/null and b/apps/documentation/public/organisations/organisations-create.webp differ diff --git a/apps/documentation/public/organisations/organisations-member-invite.webp b/apps/documentation/public/organisations/organisations-member-invite.webp new file mode 100644 index 000000000..91fbeaa77 Binary files /dev/null and b/apps/documentation/public/organisations/organisations-member-invite.webp differ diff --git a/apps/documentation/public/public-profile/documenso-enable-public-profile-settings.webp b/apps/documentation/public/public-profile/documenso-enable-public-profile-settings.webp new file mode 100644 index 000000000..b37621a45 Binary files /dev/null and b/apps/documentation/public/public-profile/documenso-enable-public-profile-settings.webp differ diff --git a/apps/documentation/public/public-profile/documenso-public-profile-settings.webp b/apps/documentation/public/public-profile/documenso-public-profile-settings.webp index d7c6e1763..bfd4dfac7 100644 Binary files a/apps/documentation/public/public-profile/documenso-public-profile-settings.webp and b/apps/documentation/public/public-profile/documenso-public-profile-settings.webp differ diff --git a/apps/documentation/public/teams/document-visibility-settings.webp b/apps/documentation/public/teams/document-visibility-settings.webp index 7022a3584..d726efc77 100644 Binary files a/apps/documentation/public/teams/document-visibility-settings.webp and b/apps/documentation/public/teams/document-visibility-settings.webp differ diff --git a/apps/documentation/public/teams/team-create-dialog.webp b/apps/documentation/public/teams/team-create-dialog.webp new file mode 100644 index 000000000..25c4f1a5b Binary files /dev/null and b/apps/documentation/public/teams/team-create-dialog.webp differ diff --git a/apps/documentation/public/teams/team-create.webp b/apps/documentation/public/teams/team-create.webp new file mode 100644 index 000000000..1b1801805 Binary files /dev/null and b/apps/documentation/public/teams/team-create.webp differ diff --git a/apps/documentation/public/teams/team-preferences.webp b/apps/documentation/public/teams/team-preferences.webp deleted file mode 100644 index efb6f00f7..000000000 Binary files a/apps/documentation/public/teams/team-preferences.webp and /dev/null differ diff --git a/apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx new file mode 100644 index 000000000..840a4bb5d --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx @@ -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; + +const ZCreateAdminOrganisationFormSchema = ZCreateAdminOrganisationRequestSchema.shape.data.pick({ + name: true, +}); + +type TCreateOrganisationFormSchema = z.infer; + +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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create organisation + + + + Create an organisation for this user + + + + + +
+ ( + + + Organisation Name + + + + + + + )} + /> + + + + + You will need to configure any claims or subscription after creating this + organisation + + + + + {/* ( + + + Default claim ID + + + + + + Leave blank to use the default free claim + + + + )} + /> */} + + + + + + +
+ + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/claim-create-dialog.tsx b/apps/remix/app/components/dialogs/claim-create-dialog.tsx new file mode 100644 index 000000000..c3564e7e0 --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-create-dialog.tsx @@ -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; + +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 ( + + e.stopPropagation()} asChild={true}> + + + + + + + Create Subscription Claim + + + Fill in the details to create a new subscription claim. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/claim-delete-dialog.tsx b/apps/remix/app/components/dialogs/claim-delete-dialog.tsx new file mode 100644 index 000000000..61124fa9d --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-delete-dialog.tsx @@ -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 ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Delete Subscription Claim + + + Are you sure you want to delete the following claim? + + + + + + {claimLocked ? This claim is locked and cannot be deleted. : claimName} + + + + + + + {!claimLocked && ( + + )} + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/claim-update-dialog.tsx b/apps/remix/app/components/dialogs/claim-update-dialog.tsx new file mode 100644 index 000000000..539343d40 --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-update-dialog.tsx @@ -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 ( + + e.stopPropagation()}> + {trigger} + + + + + + Update Subscription Claim + + + Modify the details of the subscription claim. + + + + + await updateClaim({ + id: claim.id, + data, + }) + } + formSubmitTrigger={ + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index c89e346a0..746ef1570 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -28,7 +28,6 @@ type DocumentDeleteDialogProps = { onDelete?: () => Promise | void; status: DocumentStatus; documentTitle: string; - teamId?: number; canManageDocument: boolean; }; diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 754fa2596..bb87f99dc 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -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({ diff --git a/apps/remix/app/components/dialogs/document-move-dialog.tsx b/apps/remix/app/components/dialogs/document-move-dialog.tsx deleted file mode 100644 index 1e0632531..000000000 --- a/apps/remix/app/components/dialogs/document-move-dialog.tsx +++ /dev/null @@ -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(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 ( - - - - - Move Document to Team - - - Select a team to move this document to. This action cannot be undone. - - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx index e8aefd0c0..860230b5f 100644 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx @@ -33,7 +33,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'; export type DocumentMoveToFolderDialogProps = { documentId: number; @@ -57,8 +57,9 @@ export const DocumentMoveToFolderDialog = ({ }: DocumentMoveToFolderDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZMoveDocumentFormSchema), @@ -94,6 +95,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 +110,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); diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index 0847eca0f..b3dc69503 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -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 { const { user } = useSession(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const { toast } = useToast(); const { _ } = useLingui(); diff --git a/apps/remix/app/components/dialogs/folder-create-dialog.tsx b/apps/remix/app/components/dialogs/folder-create-dialog.tsx index c0544ba3a..9451b57a1 100644 --- a/apps/remix/app/components/dialogs/folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -34,7 +34,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; const ZCreateFolderFormSchema = z.object({ name: z.string().min(1, { message: 'Folder name is required' }), @@ -52,7 +52,7 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp const { folderId } = useParams(); const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); @@ -75,13 +75,13 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp setIsCreateFolderOpen(false); + const documentsPath = formatDocumentsPath(team.url); + + await navigate(`${documentsPath}/f/${newFolder.id}`); + toast({ description: 'Folder created successfully', }); - - const documentsPath = formatDocumentsPath(team?.url); - - void navigate(`${documentsPath}/f/${newFolder.id}`); } catch (err) { const error = AppError.parseError(err); @@ -124,7 +124,7 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp Create New Folder - Enter a name for your new folder. Folders help you organize your documents. + Enter a name for your new folder. Folders help you organise your documents. diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx new file mode 100644 index 000000000..f57789a74 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -0,0 +1,425 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { ExternalLinkIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { Link, useSearchParams } from 'react-router'; +import { match } from 'ts-pattern'; +import type { z } from 'zod'; + +import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans'; +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; +import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +export const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({ + name: true, +}); + +export type TCreateOrganisationFormSchema = z.infer; + +export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + const { refreshSession } = useSession(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const actionSearchParam = searchParams?.get('action'); + + const [step, setStep] = useState<'billing' | 'create'>( + IS_BILLING_ENABLED() ? 'billing' : 'create', + ); + + const [selectedPriceId, setSelectedPriceId] = useState(''); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(ZCreateOrganisationFormSchema), + defaultValues: { + name: '', + }, + }); + + const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation(); + + const { data: plansData } = trpc.billing.plans.get.useQuery(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]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + {match(step) + .with('billing', () => ( + <> + + + Select a plan + + + + Select a plan to continue + + +
+ {plansData ? ( + + ) : ( + + )} + + + + + + +
+ + )) + .with('create', () => ( + <> + + + Create organisation + + + + Create an organisation to collaborate with teams + + + +
+ +
+ ( + + + Organisation Name + + + + + + + )} + /> + + + {IS_BILLING_ENABLED() ? ( + + ) : ( + + )} + + + +
+
+ + + )) + + .exhaustive()} +
+
+ ); +}; + +// This is separated from the internal claims constant because we need to use the msg +// macro which would cause import issues. +const internalClaimsDescription: { + [key in INTERNAL_CLAIM_ID]: MessageDescriptor | string; +} = { + [INTERNAL_CLAIM_ID.FREE]: msg`5 Documents a month`, + [INTERNAL_CLAIM_ID.INDIVIDUAL]: msg`Unlimited documents, API and more`, + [INTERNAL_CLAIM_ID.TEAM]: msg`Embedding, 5 members included and more`, + [INTERNAL_CLAIM_ID.PLATFORM]: msg`Whitelabeling, unlimited members and more`, + [INTERNAL_CLAIM_ID.ENTERPRISE]: '', + [INTERNAL_CLAIM_ID.EARLY_ADOPTER]: '', +}; + +type BillingPlanFormProps = { + value: string; + onChange: (priceId: string) => void; + plans: InternalClaimPlans; + canCreateFreeOrganisation: boolean; +}; + +const BillingPlanForm = ({ + value, + onChange, + plans, + canCreateFreeOrganisation, +}: BillingPlanFormProps) => { + const { t } = useLingui(); + + const [billingPeriod, setBillingPeriod] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice'); + + const dynamicPlans = useMemo(() => { + return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.TEAM, INTERNAL_CLAIM_ID.PLATFORM].map( + (planId) => { + const plan = plans[planId]; + + return { + id: planId, + name: plan.name, + description: parseMessageDescriptorMacro(t, internalClaimsDescription[planId]), + monthlyPrice: plan.monthlyPrice, + yearlyPrice: plan.yearlyPrice, + }; + }, + ); + }, [plans]); + + useEffect(() => { + if (value === '' && !canCreateFreeOrganisation) { + onChange(dynamicPlans[0][billingPeriod]?.id ?? ''); + } + }, [value]); + + const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => { + const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value); + + setBillingPeriod(billingPeriod); + + onChange(plan?.[billingPeriod]?.id ?? Object.keys(plans)[0]); + }; + + return ( +
+ onBillingPeriodChange(value as 'monthlyPrice' | 'yearlyPrice')} + > + + + Monthly + + + Yearly + + + + +
+ + + {dynamicPlans.map((plan) => ( + + ))} + + +
+

+ Enterprise +

+

+ Contact sales here + +

+
+ +
+ +
+ + Compare all plans and features in detail + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx new file mode 100644 index 000000000..10b53833b --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -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 ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure you wish to delete this organisation? + + + + + You are about to delete {organisation.name}. + All data related to this organisation such as teams, documents, and all other + resources will be deleted. This action is irreversible. + + + + +
+ +
+ ( + + + + Confirm by typing {deleteMessage} + + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx new file mode 100644 index 000000000..4f80b318a --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx @@ -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; + +const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({ + name: true, + memberIds: true, + organisationRole: true, +}); + +type TCreateOrganisationGroupFormSchema = z.infer; + +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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create group + + + + Organise your members into groups which can be assigned to teams + + + +
+ +
+ ( + + + Group Name + + + + + + + )} + /> + + ( + + + Organisation role + + + + + + + + )} + /> + + ( + + + Members + + + + ({ + label: member.name, + value: member.id, + }))} + loading={isLoadingMembers} + selectedValues={field.value} + onChange={field.onChange} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select members`} + /> + + + + Select the members to add to this group + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx new file mode 100644 index 000000000..17a389622 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx @@ -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 ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following group from{' '} + {organisation.name}. + + + + + + + {organisationGroupName} + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx b/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx new file mode 100644 index 000000000..fb1c7a914 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx @@ -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 ( + !isLeavingOrganisation && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + You are about to leave the following organisation. + + + + + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx new file mode 100644 index 000000000..a46151d3b --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx @@ -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 ( + !isDeletingOrganisationMember && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following user from{' '} + {organisation.name}. + + + + + + {organisationMemberName}} + secondaryText={organisationMemberEmail} + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx new file mode 100644 index 000000000..1009ef4e6 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx @@ -0,0 +1,478 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; +import Papa, { type ParseResult } from 'papaparse'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations'; +import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; +import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationMemberInviteDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZInviteOrganisationMembersFormSchema = z + .object({ + invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations, + }) + // Display exactly which rows are duplicates. + .superRefine((items, ctx) => { + const uniqueEmails = new Map(); + + 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; + +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(null); + const [invitationType, setInvitationType] = useState('INDIVIDUAL'); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const form = useForm({ + 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) => { + 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) => { + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Invite organisation members + + + + An email containing an invitation will be sent to each member. + + + + {dialogState === 'loading' && } + + {dialogState === 'alert' && ( + <> + + + + Your plan does not support inviting members. Please upgrade or your plan or + contact sales at support@documenso.com{' '} + if you would like to discuss your options. + + + + + + + + + )} + + {dialogState === 'form' && ( + setInvitationType(value as TabTypes)} + > + + + + Invite Members + + + + Bulk Import + + + + +
+ +
+
+ {organisationMemberInvites.map((organisationMemberInvite, index) => ( +
+ ( + + {index === 0 && ( + + Email address + + )} + + + + + + )} + /> + + ( + + {index === 0 && ( + + Organisation Role + + )} + + + + + + )} + /> + + +
+ ))} +
+ + + + + + + + +
+
+ +
+ + +
+ + fileInputRef.current?.click()} + > + + +

+ Click here to upload +

+ + +
+
+ + + + +
+
+
+ )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx new file mode 100644 index 000000000..07db162a2 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -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; + +const ZUpdateOrganisationMemberFormSchema = z.object({ + role: z.nativeEnum(OrganisationMemberRole), +}); + +type ZUpdateOrganisationMemberSchema = z.infer; + +export const OrganisationMemberUpdateDialog = ({ + currentUserOrganisationRole, + trigger, + organisationId, + organisationMemberId, + organisationMemberName, + organisationMemberRole, + ...props +}: OrganisationMemberUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update organisation member + + + + + You are currently updating{' '} + {organisationMemberName}. + + + + +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx index d6a13f456..bcd624290 100644 --- a/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx +++ b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx @@ -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(initialTemplateId); diff --git a/apps/remix/app/components/dialogs/session-logout-all-dialog.tsx b/apps/remix/app/components/dialogs/session-logout-all-dialog.tsx new file mode 100644 index 000000000..c70fb062e --- /dev/null +++ b/apps/remix/app/components/dialogs/session-logout-all-dialog.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; + +import { authClient } from '@documenso/auth/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type SessionLogoutAllDialogProps = { + onSuccess?: () => Promise; + disabled?: boolean; +}; + +export const SessionLogoutAllDialog = ({ onSuccess, disabled }: SessionLogoutAllDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleSignOutAllSessions = async () => { + setIsLoading(true); + + try { + await authClient.signOutAllSessions(); + + if (onSuccess) { + await onSuccess(); + } + + toast({ + title: t`Sessions have been revoked`, + }); + + setIsOpen(false); + } catch (error) { + console.error(error); + + toast({ + title: t`Error`, + description: t`Failed to sign out all sessions`, + variant: 'destructive', + }); + } + + setIsLoading(false); + }; + + return ( + (isLoading ? undefined : setIsOpen(value))}> + + + + + + + + Revoke all sessions + + + + This will sign you out of all other devices. You will need to sign in again on those + devices to continue using your account. + + + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx b/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx deleted file mode 100644 index 038c78504..000000000 --- a/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Loader, TagIcon } from 'lucide-react'; - -import { trpc } from '@documenso/trpc/react'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export type TeamCheckoutCreateDialogProps = { - pendingTeamId: number | null; - onClose: () => void; -} & Omit; - -const MotionCard = motion(Card); - -export const TeamCheckoutCreateDialog = ({ - pendingTeamId, - onClose, - ...props -}: TeamCheckoutCreateDialogProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly'); - - const { data, isLoading } = trpc.team.getTeamPrices.useQuery(); - - const { mutateAsync: createCheckout, isPending: isCreatingCheckout } = - trpc.team.createTeamPendingCheckout.useMutation({ - onSuccess: (checkoutUrl) => { - window.open(checkoutUrl, '_blank'); - onClose(); - }, - onError: () => - toast({ - title: _(msg`Something went wrong`), - description: _( - msg`We were unable to create a checkout session. Please try again, or contact support`, - ), - variant: 'destructive', - }), - }); - - const selectedPrice = useMemo(() => { - if (!data) { - return null; - } - - return data[interval]; - }, [data, interval]); - - const handleOnOpenChange = (open: boolean) => { - if (pendingTeamId === null) { - return; - } - - if (!open) { - onClose(); - } - }; - - if (pendingTeamId === null) { - return null; - } - - return ( - - - - - Team checkout - - - - Payment is required to finalise the creation of your team. - - - - {(isLoading || !data) && ( -
- {isLoading ? ( - - ) : ( -

- Something went wrong -

- )} -
- )} - - {data && selectedPrice && !isLoading && ( -
- setInterval(value as 'monthly' | 'yearly')} - value={interval} - className="mb-4" - > - - {[data.monthly, data.yearly].map((price) => ( - - {price.friendlyInterval} - - ))} - - - - - - - {selectedPrice.interval === 'monthly' ? ( -
- $50 USD per month -
- ) : ( -
- - $480 USD per year - -
- - 20% off -
-
- )} - -
-

- This price includes minimum 5 seats. -

- -

- Adding and removing seats will adjust your invoice accordingly. -

-
-
-
-
- - - - - - -
- )} -
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-create-dialog.tsx b/apps/remix/app/components/dialogs/team-create-dialog.tsx index 0b49e9b6d..65740ce67 100644 --- a/apps/remix/app/components/dialogs/team-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-create-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; @@ -7,15 +7,18 @@ import { Trans } from '@lingui/react/macro'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { useForm } from 'react-hook-form'; import { useSearchParams } from 'react-router'; -import { useNavigate } from 'react-router'; import type { z } from 'zod'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; +import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Dialog, DialogContent, @@ -34,29 +37,37 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type TeamCreateDialogProps = { trigger?: React.ReactNode; + onCreated?: () => Promise; } & Omit; -const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ +const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({ teamName: true, teamUrl: true, + inheritMembers: true, }); type TCreateTeamFormSchema = z.infer; -export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => { +export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { refreshSession } = useSession(); - const navigate = useNavigate(); const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); + const organisation = useCurrentOrganisation(); const [open, setOpen] = useState(false); + const { data: fullOrganisation } = trpc.organisation.get.useQuery({ + organisationReference: organisation.id, + }); + const actionSearchParam = searchParams?.get('action'); const form = useForm({ @@ -64,24 +75,25 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = defaultValues: { teamName: '', teamUrl: '', + inheritMembers: true, }, }); - const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation(); + const { mutateAsync: createTeam } = trpc.team.create.useMutation(); - const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => { + const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => { try { - const response = await createTeam({ + await createTeam({ + organisationId: organisation.id, teamName, teamUrl, + inheritMembers, }); setOpen(false); - if (response.paymentRequired) { - await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`); - return; - } + await onCreated?.(); + await refreshSession(); toast({ title: _(msg`Success`), @@ -114,6 +126,26 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = return text.toLowerCase().replace(/\s+/g, '-'); }; + const dialogState = useMemo(() => { + if (!fullOrganisation) { + return 'loading'; + } + + if (!IS_BILLING_ENABLED()) { + return 'form'; + } + + if (fullOrganisation.organisationClaim.teamCount === 0) { + return 'form'; + } + + if (fullOrganisation.organisationClaim.teamCount <= fullOrganisation.teams.length) { + return 'alert'; + } + + return 'form'; + }, [fullOrganisation]); + useEffect(() => { if (actionSearchParam === 'add-team') { setOpen(true); @@ -145,89 +177,141 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = Create team - + Create a team to collaborate with your team members. -
- -
} + + {dialogState === 'alert' && ( + <> + - ( - - - Team Name - - - { - const oldGeneratedUrl = mapTextToUrl(field.value); - const newGeneratedUrl = mapTextToUrl(event.target.value); + + + You have reached the maximum number of teams for your plan. Please contact sales + at support@documenso.com if you would + like to adjust your plan. + + + - const urlField = form.getValues('teamUrl'); - if (urlField === oldGeneratedUrl) { - form.setValue('teamUrl', newGeneratedUrl); - } + + + + + )} - field.onChange(event); - }} - /> - - - - )} - /> + {dialogState === 'form' && ( + + +
+ ( + + + Team Name + + + { + const oldGeneratedUrl = mapTextToUrl(field.value); + const newGeneratedUrl = mapTextToUrl(event.target.value); - ( - - - Team URL - - - - - {!form.formState.errors.teamUrl && ( - - {field.value ? ( - `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` - ) : ( - A unique URL to identify your team - )} - - )} + const urlField = form.getValues('teamUrl'); + if (urlField === oldGeneratedUrl) { + form.setValue('teamUrl', newGeneratedUrl); + } - - - )} - /> + field.onChange(event); + }} + /> + + + + )} + /> - - + ( + + + Team URL + + + + + {!form.formState.errors.teamUrl && ( + + {field.value ? ( + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` + ) : ( + A unique URL to identify your team + )} + + )} - - -
- - + + + )} + /> + + ( + + +
+ + + +
+
+
+ )} + /> + + + + + + +
+ + + )} ); diff --git a/apps/remix/app/components/dialogs/team-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-delete-dialog.tsx index b297cbb2a..670ba1a2a 100644 --- a/apps/remix/app/components/dialogs/team-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-delete-dialog.tsx @@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { AppError } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -35,15 +36,22 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type TeamDeleteDialogProps = { teamId: number; teamName: string; + redirectTo?: string; trigger?: React.ReactNode; }; -export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => { +export const TeamDeleteDialog = ({ + trigger, + teamId, + teamName, + redirectTo, +}: TeamDeleteDialogProps) => { const navigate = useNavigate(); const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); + const { refreshSession } = useSession(); const deleteMessage = _(msg`delete ${teamName}`); @@ -60,19 +68,23 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog }, }); - const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation(); + const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation(); const onFormSubmit = async () => { try { await deleteTeam({ teamId }); + await refreshSession(); + toast({ title: _(msg`Success`), description: _(msg`Your team has been successfully deleted.`), duration: 5000, }); - await navigate('/settings/teams'); + if (redirectTo) { + await navigate(redirectTo); + } setOpen(false); } catch (err) { @@ -113,7 +125,7 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog {trigger ?? ( )} diff --git a/apps/remix/app/components/dialogs/team-email-add-dialog.tsx b/apps/remix/app/components/dialogs/team-email-add-dialog.tsx index 161c2c0eb..56e54da72 100644 --- a/apps/remix/app/components/dialogs/team-email-add-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-add-dialog.tsx @@ -61,12 +61,12 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi }, }); - const { mutateAsync: createTeamEmailVerification, isPending } = - trpc.team.createTeamEmailVerification.useMutation(); + const { mutateAsync: sendTeamEmailVerification, isPending } = + trpc.team.email.verification.send.useMutation(); const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => { try { - await createTeamEmailVerification({ + await sendTeamEmailVerification({ teamId, name, email, diff --git a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx index ec050961c..d9b780657 100644 --- a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx @@ -48,7 +48,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele const { revalidate } = useRevalidator(); const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } = - trpc.team.deleteTeamEmail.useMutation({ + trpc.team.email.delete.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), @@ -67,7 +67,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele }); const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } = - trpc.team.deleteTeamEmailVerification.useMutation({ + trpc.team.email.verification.delete.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx index bde700949..dd5411b59 100644 --- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx @@ -61,7 +61,7 @@ export const TeamEmailUpdateDialog = ({ }, }); - const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation(); + const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation(); const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { try { diff --git a/apps/remix/app/components/dialogs/team-group-create-dialog.tsx b/apps/remix/app/components/dialogs/team-group-create-dialog.tsx new file mode 100644 index 000000000..60ebaec4b --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-create-dialog.tsx @@ -0,0 +1,305 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { OrganisationGroupType, TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupCreateDialogProps = Omit; + +const ZAddTeamMembersFormSchema = z.object({ + groups: z.array( + z.object({ + organisationGroupId: z.string(), + teamRole: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +type TAddTeamMembersFormSchema = z.infer; + +export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT'); + + const { t } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZAddTeamMembersFormSchema), + defaultValues: { + groups: [], + }, + }); + + const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation(); + + const organisationGroupQuery = trpc.organisation.group.find.useQuery({ + organisationId: team.organisationId, + perPage: 100, // Won't really work if they somehow have more than 100 groups. + types: [OrganisationGroupType.CUSTOM], + }); + + const teamGroupQuery = trpc.team.group.find.useQuery({ + teamId: team.id, + perPage: 100, // Won't really work if they somehow have more than 100 groups. + }); + + const avaliableOrganisationGroups = useMemo(() => { + const organisationGroups = organisationGroupQuery.data?.data ?? []; + const teamGroups = teamGroupQuery.data?.data ?? []; + + return organisationGroups.filter( + (group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id), + ); + }, [organisationGroupQuery, teamGroupQuery]); + + const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => { + try { + await createTeamGroups({ + teamId: team.id, + groups, + }); + + toast({ + title: t`Success`, + description: t`Team members have been added.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to add team members. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + setStep('SELECT'); + } + }, [open, form]); + + return ( + + e.stopPropagation()} asChild> + + + + + {match(step) + .with('SELECT', () => ( + + + Add groups + + + + Select groups of members to add to the team. + + + )) + .with('ROLES', () => ( + + + Add group roles + + + + Configure the team roles for each group + + + )) + .exhaustive()} + +
+ +
+ {step === 'SELECT' && ( + <> + ( + + + Groups + + + + ({ + label: group.name ?? group.organisationRole, + value: group.id, + }))} + loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading} + selectedValues={field.value.map( + ({ organisationGroupId }) => organisationGroupId, + )} + onChange={(value) => { + field.onChange( + value.map((organisationGroupId) => ({ + organisationGroupId, + teamRole: + field.value.find( + (value) => value.organisationGroupId === organisationGroupId, + )?.teamRole || TeamMemberRole.MEMBER, + })), + ); + }} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select groups`} + /> + + + + Select groups to add to this team + + + )} + /> + + + + + + + + )} + + {step === 'ROLES' && ( + <> +
+ {form.getValues('groups').map((group, index) => ( +
+
+ {index === 0 && ( + + Group + + )} + id === group.organisationGroupId, + )?.name || t`Untitled Group` + } + /> +
+ + ( + + {index === 0 && ( + + Team Role + + )} + + + + + + )} + /> +
+ ))} +
+ + + + + + + + )} +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx new file mode 100644 index 000000000..f42fd4630 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { TeamMemberRole } from '@prisma/client'; + +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupDeleteDialogProps = { + trigger?: React.ReactNode; + teamGroupId: string; + teamGroupName: string; + teamGroupRole: TeamMemberRole; +}; + +export const TeamGroupDeleteDialog = ({ + trigger, + teamGroupId, + teamGroupName, + teamGroupRole, +}: TeamGroupDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.team.group.delete.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully removed this group from the team.`), + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to remove this group. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following group from{' '} + {team.name}. + + + + + {isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? ( + <> + + + {teamGroupName} + + + +
+ + + + + +
+ + ) : ( + <> + + + You cannot delete a group which has a higher role than you. + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-group-update-dialog.tsx b/apps/remix/app/components/dialogs/team-group-update-dialog.tsx new file mode 100644 index 000000000..c57755e87 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-update-dialog.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; +import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupUpdateDialogProps = { + trigger?: React.ReactNode; + teamGroupId: string; + teamGroupName: string; + teamGroupRole: TeamMemberRole; +} & Omit; + +const ZUpdateTeamGroupFormSchema = z.object({ + role: z.nativeEnum(TeamMemberRole), +}); + +type ZUpdateTeamGroupSchema = z.infer; + +export const TeamGroupUpdateDialog = ({ + trigger, + teamGroupId, + teamGroupName, + teamGroupRole, + ...props +}: TeamGroupUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamGroupFormSchema), + defaultValues: { + role: teamGroupRole, + }, + }); + + const { mutateAsync: updateTeamGroup } = trpc.team.group.update.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateTeamGroupSchema) => { + try { + await updateTeamGroup({ + id: teamGroupId, + data: { + teamRole: role, + }, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`You have updated the team group.`), + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to update this team member. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, team.currentTeamRole, teamGroupRole, form, toast]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update team group + + + + + You are currently updating the {teamGroupName} team + group. + + + + + {isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? ( +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ + ) : ( + <> + + + You cannot modify a group which has a higher role than you. + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx b/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx new file mode 100644 index 000000000..c7352b7fe --- /dev/null +++ b/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx @@ -0,0 +1,93 @@ +import { Trans, useLingui } from '@lingui/react/macro'; +import type { TeamGroup } from '@prisma/client'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +type TeamMemberInheritDisableDialogProps = { + group: TeamGroup; +}; + +export const TeamMemberInheritDisableDialog = ({ group }: TeamMemberInheritDisableDialogProps) => { + const { toast } = useToast(); + const { t } = useLingui(); + + const team = useCurrentTeam(); + + const deleteGroupMutation = trpc.team.group.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Access disabled`, + duration: 5000, + }); + }, + onError: () => { + toast({ + title: t`Something went wrong`, + description: t`We encountered an unknown error while attempting to disable access.`, + variant: 'destructive', + duration: 5000, + }); + }, + }); + + return ( + + + + + + + + + Are you sure? + + + + + You are about to remove default access to this team for all organisation members. Any + members not explicitly added to this team will no longer have access. + + + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx b/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx new file mode 100644 index 000000000..1abece3fa --- /dev/null +++ b/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx @@ -0,0 +1,109 @@ +import { Trans, useLingui } from '@lingui/react/macro'; +import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export const TeamMemberInheritEnableDialog = () => { + const organisation = useCurrentOrganisation(); + const team = useCurrentTeam(); + + const { toast } = useToast(); + const { t } = useLingui(); + + const { mutateAsync: createTeamGroups, isPending } = trpc.team.group.createMany.useMutation({ + onSuccess: () => { + toast({ + title: t`Access enabled`, + duration: 5000, + }); + }, + onError: () => { + toast({ + title: t`Something went wrong`, + description: t`We encountered an unknown error while attempting to enable access.`, + variant: 'destructive', + duration: 5000, + }); + }, + }); + + const organisationGroupQuery = trpc.organisation.group.find.useQuery({ + organisationId: organisation.id, + perPage: 1, + types: [OrganisationGroupType.INTERNAL_ORGANISATION], + organisationRoles: [OrganisationMemberRole.MEMBER], + }); + + const enableAccessGroup = async () => { + if (!organisationGroupQuery.data?.data[0]?.id) { + return; + } + + await createTeamGroups({ + teamId: team.id, + groups: [ + { + organisationGroupId: organisationGroupQuery.data?.data[0]?.id, + teamRole: TeamMemberRole.MEMBER, + }, + ], + }); + }; + + return ( + + + + + + + + + Are you sure? + + + + + You are about to give all organisation members access to this team under their + organisation role. + + + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/team-leave-dialog.tsx b/apps/remix/app/components/dialogs/team-leave-dialog.tsx deleted file mode 100644 index a6b6246a6..000000000 --- a/apps/remix/app/components/dialogs/team-leave-dialog.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type { TeamMemberRole } from '@prisma/client'; - -import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; -import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { trpc } from '@documenso/trpc/react'; -import { Alert } from '@documenso/ui/primitives/alert'; -import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export type TeamLeaveDialogProps = { - teamId: number; - teamName: string; - teamAvatarImageId?: string | null; - role: TeamMemberRole; - trigger?: React.ReactNode; -}; - -export const TeamLeaveDialog = ({ - trigger, - teamId, - teamName, - teamAvatarImageId, - role, -}: TeamLeaveDialogProps) => { - const [open, setOpen] = useState(false); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const { mutateAsync: leaveTeam, isPending: isLeavingTeam } = trpc.team.leaveTeam.useMutation({ - onSuccess: () => { - toast({ - title: _(msg`Success`), - description: _(msg`You have successfully left this team.`), - duration: 5000, - }); - - setOpen(false); - }, - onError: () => { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to leave this team. Please try again later.`, - ), - variant: 'destructive', - duration: 10000, - }); - }, - }); - - return ( - !isLeavingTeam && setOpen(value)}> - - {trigger ?? ( - - )} - - - - - - Are you sure? - - - - You are about to leave the following team. - - - - - - - -
- - - - - -
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx new file mode 100644 index 000000000..b691d910c --- /dev/null +++ b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx @@ -0,0 +1,305 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamMemberCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZAddTeamMembersFormSchema = z.object({ + members: z.array( + z.object({ + organisationMemberId: z.string(), + teamRole: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +type TAddTeamMembersFormSchema = z.infer; + +export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT'); + + const { t } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZAddTeamMembersFormSchema), + defaultValues: { + members: [], + }, + }); + + const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation(); + + const organisationMemberQuery = trpc.organisation.member.find.useQuery({ + organisationId: team.organisationId, + }); + + const teamMemberQuery = trpc.team.member.find.useQuery({ + teamId: team.id, + }); + + const avaliableOrganisationMembers = useMemo(() => { + const organisationMembers = organisationMemberQuery.data?.data ?? []; + const teamMembers = teamMemberQuery.data?.data ?? []; + + return organisationMembers.filter( + (member) => !teamMembers.some((teamMember) => teamMember.id === member.id), + ); + }, [organisationMemberQuery, teamMemberQuery]); + + const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => { + try { + await createTeamMembers({ + teamId: team.id, + organisationMembers: members, + }); + + toast({ + title: t`Success`, + description: t`Team members have been added.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to add team members. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + setStep('SELECT'); + } + }, [open, form]); + + return ( + + e.stopPropagation()} asChild> + + + + + {match(step) + .with('SELECT', () => ( + + + Add members + + + + Select members or groups of members to add to the team. + + + )) + .with('MEMBERS', () => ( + + + Add members roles + + + + Configure the team roles for each member + + + )) + .exhaustive()} + +
+ +
+ {step === 'SELECT' && ( + <> + ( + + + Members + + + + ({ + label: member.name, + value: member.id, + }))} + loading={organisationMemberQuery.isLoading} + selectedValues={field.value.map( + (member) => member.organisationMemberId, + )} + onChange={(value) => { + field.onChange( + value.map((organisationMemberId) => ({ + organisationMemberId, + teamRole: + field.value.find( + (member) => + member.organisationMemberId === organisationMemberId, + )?.teamRole || TeamMemberRole.MEMBER, + })), + ); + }} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select members`} + /> + + + + Select members to add to this team + + + )} + /> + + + + + + + + )} + + {step === 'MEMBERS' && ( + <> +
+ {form.getValues('members').map((member, index) => ( +
+
+ {index === 0 && ( + + Member + + )} + id === member.organisationMemberId, + )?.name || '' + } + /> +
+ + ( + + {index === 0 && ( + + Team Role + + )} + + + + + + )} + /> +
+ ))} +
+ + + + + + + + )} +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx index dedda2003..82a4d7a04 100644 --- a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx @@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { trpc } from '@documenso/trpc/react'; -import { Alert } from '@documenso/ui/primitives/alert'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -22,9 +22,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type TeamMemberDeleteDialogProps = { teamId: number; teamName: string; - teamMemberId: number; - teamMemberName: string; - teamMemberEmail: string; + memberId: string; + memberName: string; + memberEmail: string; + isInheritMemberEnabled: boolean | null; trigger?: React.ReactNode; }; @@ -32,17 +33,18 @@ export const TeamMemberDeleteDialog = ({ trigger, teamId, teamName, - teamMemberId, - teamMemberName, - teamMemberEmail, + memberId, + memberName, + memberEmail, + isInheritMemberEnabled, }: TeamMemberDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); - const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } = - trpc.team.deleteTeamMembers.useMutation({ + const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } = + trpc.team.member.delete.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), @@ -69,7 +71,7 @@ export const TeamMemberDeleteDialog = ({ {trigger ?? ( )} @@ -88,29 +90,42 @@ export const TeamMemberDeleteDialog = ({
- - {teamMemberName}} - secondaryText={teamMemberEmail} - /> - + {isInheritMemberEnabled ? ( + + + + You cannot remove members from this team if the inherit member feature is enabled. + + + + ) : ( + + {memberName}} + secondaryText={memberEmail} + /> + + )}
- + {!isInheritMemberEnabled && ( + + )}
diff --git a/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx deleted file mode 100644 index dac4f8fce..000000000 --- a/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { TeamMemberRole } from '@prisma/client'; -import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; -import Papa, { type ParseResult } from 'papaparse'; -import { useFieldArray, useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { downloadFile } from '@documenso/lib/client-only/download-file'; -import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; -import { trpc } from '@documenso/trpc/react'; -import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCurrentTeam } from '~/providers/team'; - -export type TeamMemberInviteDialogProps = { - trigger?: React.ReactNode; -} & Omit; - -const ZInviteTeamMembersFormSchema = z - .object({ - invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, - }) - // Display exactly which rows are duplicates. - .superRefine((items, ctx) => { - const uniqueEmails = new Map(); - - for (const [index, invitation] of items.invitations.entries()) { - const email = invitation.email.toLowerCase(); - - const firstFoundIndex = uniqueEmails.get(email); - - if (firstFoundIndex === undefined) { - uniqueEmails.set(email, index); - continue; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Emails must be unique', - path: ['invitations', index, 'email'], - }); - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Emails must be unique', - path: ['invitations', firstFoundIndex, 'email'], - }); - } - }); - -type TInviteTeamMembersFormSchema = z.infer; - -type TabTypes = 'INDIVIDUAL' | 'BULK'; - -const ZImportTeamMemberSchema = z.array( - z.object({ - email: z.string().email(), - role: z.nativeEnum(TeamMemberRole), - }), -); - -export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => { - const [open, setOpen] = useState(false); - const fileInputRef = useRef(null); - const [invitationType, setInvitationType] = useState('INDIVIDUAL'); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const team = useCurrentTeam(); - - const form = useForm({ - resolver: zodResolver(ZInviteTeamMembersFormSchema), - defaultValues: { - invitations: [ - { - email: '', - role: TeamMemberRole.MEMBER, - }, - ], - }, - }); - - const { - append: appendTeamMemberInvite, - fields: teamMemberInvites, - remove: removeTeamMemberInvite, - } = useFieldArray({ - control: form.control, - name: 'invitations', - }); - - const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation(); - - const onAddTeamMemberInvite = () => { - appendTeamMemberInvite({ - email: '', - role: TeamMemberRole.MEMBER, - }); - }; - - const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => { - try { - await createTeamMemberInvites({ - teamId: team.id, - invitations, - }); - - toast({ - title: _(msg`Success`), - description: _(msg`Team invitations have been sent.`), - duration: 5000, - }); - - setOpen(false); - } catch { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to invite team members. Please try again later.`, - ), - variant: 'destructive', - }); - } - }; - - useEffect(() => { - if (!open) { - form.reset(); - setInvitationType('INDIVIDUAL'); - } - }, [open, form]); - - const onFileInputChange = (e: React.ChangeEvent) => { - 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) => { - const members = results.data.map((row) => { - const [email, role] = row; - - return { - email: email.trim(), - role: role.trim().toUpperCase(), - }; - }); - - // Remove the first row if it contains the headers. - if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') { - members.shift(); - } - - try { - const importedInvitations = ZImportTeamMemberSchema.parse(members); - - form.setValue('invitations', importedInvitations); - form.clearErrors('invitations'); - - setInvitationType('INDIVIDUAL'); - } catch (err) { - console.error(err); - - toast({ - title: _(msg`Something went wrong`), - description: _( - msg`Please check the CSV file and make sure it is according to our format`, - ), - variant: 'destructive', - }); - } - }, - }); - }; - - const downloadTemplate = () => { - const data = [ - { email: 'admin@documenso.com', role: 'Admin' }, - { email: 'manager@documenso.com', role: 'Manager' }, - { email: 'member@documenso.com', role: 'Member' }, - ]; - - const csvContent = - 'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n'); - - const blob = new Blob([csvContent], { - type: 'text/csv', - }); - - downloadFile({ - filename: 'documenso-team-member-invites-template.csv', - data: blob, - }); - }; - - return ( - !form.formState.isSubmitting && setOpen(value)} - > - e.stopPropagation()} asChild> - {trigger ?? ( - - )} - - - - - - Invite team members - - - - An email containing an invitation will be sent to each member. - - - - setInvitationType(value as TabTypes)} - > - - - - Invite Members - - - - Bulk Import - - - - -
- -
-
- {teamMemberInvites.map((teamMemberInvite, index) => ( -
- ( - - {index === 0 && ( - - Email address - - )} - - - - - - )} - /> - - ( - - {index === 0 && ( - - Role - - )} - - - - - - )} - /> - - -
- ))} -
- - - - - - - - -
-
- -
- - -
- - fileInputRef.current?.click()} - > - - -

- Click here to upload -

- - -
-
- - - - -
-
-
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-member-update-dialog.tsx b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx index e9c3a021b..b7729f734 100644 --- a/apps/remix/app/components/dialogs/team-member-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx @@ -9,7 +9,8 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; +import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -43,9 +44,9 @@ export type TeamMemberUpdateDialogProps = { currentUserTeamRole: TeamMemberRole; trigger?: React.ReactNode; teamId: number; - teamMemberId: number; - teamMemberName: string; - teamMemberRole: TeamMemberRole; + memberId: string; + memberName: string; + memberTeamRole: TeamMemberRole; } & Omit; const ZUpdateTeamMemberFormSchema = z.object({ @@ -58,9 +59,9 @@ export const TeamMemberUpdateDialog = ({ currentUserTeamRole, trigger, teamId, - teamMemberId, - teamMemberName, - teamMemberRole, + memberId, + memberName, + memberTeamRole, ...props }: TeamMemberUpdateDialogProps) => { const [open, setOpen] = useState(false); @@ -71,17 +72,17 @@ export const TeamMemberUpdateDialog = ({ const form = useForm({ resolver: zodResolver(ZUpdateTeamMemberFormSchema), defaultValues: { - role: teamMemberRole, + role: memberTeamRole, }, }); - const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation(); + const { mutateAsync: updateTeamMember } = trpc.team.member.update.useMutation(); const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => { try { await updateTeamMember({ teamId, - teamMemberId, + memberId, data: { role, }, @@ -89,7 +90,7 @@ export const TeamMemberUpdateDialog = ({ toast({ title: _(msg`Success`), - description: _(msg`You have updated ${teamMemberName}.`), + description: _(msg`You have updated ${memberName}.`), duration: 5000, }); @@ -112,7 +113,7 @@ export const TeamMemberUpdateDialog = ({ form.reset(); - if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) { + if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) { setOpen(false); toast({ @@ -121,7 +122,7 @@ export const TeamMemberUpdateDialog = ({ }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentUserTeamRole, teamMemberRole, form, toast]); + }, [open, currentUserTeamRole, memberTeamRole, form, toast]); return ( Update team member - + - You are currently updating {teamMemberName}. + You are currently updating {memberName}. @@ -170,7 +171,7 @@ export const TeamMemberUpdateDialog = ({ {TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => ( - {_(TEAM_MEMBER_ROLE_MAP[role]) ?? role} + {_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role} ))} diff --git a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx b/apps/remix/app/components/dialogs/team-transfer-dialog.tsx deleted file mode 100644 index 4e46233cc..000000000 --- a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { Loader } from 'lucide-react'; -import { useForm } from 'react-hook-form'; -import { useRevalidator } from 'react-router'; -import { z } from 'zod'; - -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; -import { trpc } from '@documenso/trpc/react'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export type TeamTransferDialogProps = { - teamId: number; - teamName: string; - ownerUserId: number; - trigger?: React.ReactNode; -}; - -export const TeamTransferDialog = ({ - trigger, - teamId, - teamName, - ownerUserId, -}: TeamTransferDialogProps) => { - const [open, setOpen] = useState(false); - - const { _ } = useLingui(); - const { toast } = useToast(); - const { revalidate } = useRevalidator(); - - const { mutateAsync: requestTeamOwnershipTransfer } = - trpc.team.requestTeamOwnershipTransfer.useMutation(); - - const { - data, - refetch: refetchTeamMembers, - isPending: loadingTeamMembers, - isLoadingError: loadingTeamMembersError, - } = trpc.team.getTeamMembers.useQuery({ - teamId, - }); - - const confirmTransferMessage = _(msg`transfer ${teamName}`); - - const ZTransferTeamFormSchema = z.object({ - teamName: z.literal(confirmTransferMessage, { - errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }), - }), - newOwnerUserId: z.string(), - clearPaymentMethods: z.boolean(), - }); - - const form = useForm>({ - resolver: zodResolver(ZTransferTeamFormSchema), - defaultValues: { - teamName: '', - clearPaymentMethods: false, - }, - }); - - const onFormSubmit = async ({ - newOwnerUserId, - clearPaymentMethods, - }: z.infer) => { - try { - await requestTeamOwnershipTransfer({ - teamId, - newOwnerUserId: Number.parseInt(newOwnerUserId), - clearPaymentMethods, - }); - - await revalidate(); - - toast({ - title: _(msg`Success`), - description: _(msg`An email requesting the transfer of this team has been sent.`), - duration: 5000, - }); - - setOpen(false); - } catch (err) { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to request a transfer of this team. Please try again later.`, - ), - variant: 'destructive', - duration: 10000, - }); - } - }; - - useEffect(() => { - if (!open) { - form.reset(); - } - }, [open, form]); - - useEffect(() => { - if (open && loadingTeamMembersError) { - void refetchTeamMembers(); - } - }, [open, loadingTeamMembersError, refetchTeamMembers]); - - const teamMembers = data - ? data.filter((teamMember) => teamMember.userId !== ownerUserId) - : undefined; - - return ( - !form.formState.isSubmitting && setOpen(value)}> - - {trigger ?? ( - - )} - - - {teamMembers && teamMembers.length > 0 ? ( - - - - Transfer team - - - - Transfer ownership of this team to a selected team member. - - - -
- -
- ( - - - New team owner - - - - - - - )} - /> - - ( - - - - Confirm by typing{' '} - {confirmTransferMessage} - - - - - - - - )} - /> - - - -
    - {IS_BILLING_ENABLED() && ( -
  • - - Any payment methods attached to this team will remain attached to this - team. Please contact us if you need to update this information. - -
  • - )} -
  • - - The selected team member will receive an email which they must accept - before the team is transferred - -
  • -
-
-
- - - - - - -
-
- -
- ) : ( - - {loadingTeamMembers ? ( - - ) : ( -

- {loadingTeamMembersError ? ( - An error occurred while loading team members. Please try again later. - ) : ( - You must have at least one other team member to transfer ownership. - )} -

- )} -
- )} -
- ); -}; diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx index cd700ac1e..610ce7ac3 100644 --- a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -21,7 +21,7 @@ import { import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; const ZBulkSendFormSchema = z.object({ file: z.instanceof(File), @@ -46,7 +46,7 @@ export const TemplateBulkSendDialog = ({ const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZBulkSendFormSchema), diff --git a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx index 8142eb848..d7e60b512 100644 --- a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx @@ -15,6 +15,7 @@ import { P, match } from 'ts-pattern'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template'; @@ -75,6 +76,8 @@ export const TemplateDirectLinkDialog = ({ token ? 'MANAGE' : 'ONBOARD', ); + const organisation = useCurrentOrganisation(); + const validDirectTemplateRecipients = useMemo( () => template.recipients.filter( @@ -237,7 +240,7 @@ export const TemplateDirectLinkDialog = ({ templates.{' '} Upgrade your account to continue! diff --git a/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx b/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx index aaf0322f4..db1da14de 100644 --- a/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx @@ -34,7 +34,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; const ZCreateFolderFormSchema = z.object({ name: z.string().min(1, { message: 'Folder name is required' }), @@ -52,10 +52,11 @@ export const TemplateFolderCreateDialog = ({ }: TemplateFolderCreateDialogProps) => { const { toast } = useToast(); const { _ } = useLingui(); - const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); const { folderId } = useParams(); + const navigate = useNavigate(); + const team = useCurrentTeam(); + const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation(); @@ -81,7 +82,7 @@ export const TemplateFolderCreateDialog = ({ description: _(msg`Folder created successfully`), }); - const templatesPath = formatTemplatesPath(team?.url); + const templatesPath = formatTemplatesPath(team.url); void navigate(`${templatesPath}/f/${newFolder.id}`); } catch (err) { @@ -126,7 +127,7 @@ export const TemplateFolderCreateDialog = ({ Create New Folder - Enter a name for your new folder. Folders help you organize your templates. + Enter a name for your new folder. Folders help you organise your templates. diff --git a/apps/remix/app/components/dialogs/template-move-dialog.tsx b/apps/remix/app/components/dialogs/template-move-dialog.tsx deleted file mode 100644 index f113317eb..000000000 --- a/apps/remix/app/components/dialogs/template-move-dialog.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { match } from 'ts-pattern'; - -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { trpc } from '@documenso/trpc/react'; -import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -type TemplateMoveDialogProps = { - templateId: number; - open: boolean; - onOpenChange: (_open: boolean) => void; - onMove?: ({ - templateId, - teamUrl, - }: { - templateId: number; - teamUrl: string; - }) => Promise | void; -}; - -export const TemplateMoveDialog = ({ - templateId, - open, - onOpenChange, - onMove, -}: TemplateMoveDialogProps) => { - const { toast } = useToast(); - const { _ } = useLingui(); - - const [selectedTeamId, setSelectedTeamId] = useState(null); - - const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery(); - - const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({ - onSuccess: async () => { - const team = teams?.find((team) => team.id === selectedTeamId); - - if (team) { - await onMove?.({ templateId, teamUrl: team.url }); - } - - toast({ - title: _(msg`Template moved`), - description: _(msg`The template has been successfully moved to the selected team.`), - duration: 5000, - }); - - onOpenChange(false); - }, - onError: (err) => { - const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with( - AppErrorCode.NOT_FOUND, - () => msg`Template not found or already associated with a team.`, - ) - .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`) - .otherwise(() => msg`An error occurred while moving the template.`); - - toast({ - title: _(msg`Error`), - description: _(errorMessage), - variant: 'destructive', - duration: 7500, - }); - }, - }); - - const handleOnMove = async () => { - if (!selectedTeamId) { - return; - } - - await moveTemplate({ templateId, teamId: selectedTeamId }); - }; - - return ( - - - - - Move Template to Team - - - Select a team to move this template to. This action cannot be undone. - - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx index c0d068ac4..4838995b5 100644 --- a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx @@ -33,7 +33,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'; export type TemplateMoveToFolderDialogProps = { templateId: number; @@ -60,7 +60,7 @@ export function TemplateMoveToFolderDialog({ const { _ } = useLingui(); const { toast } = useToast(); const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZMoveTemplateFormSchema), @@ -104,7 +104,7 @@ export function TemplateMoveToFolderDialog({ onOpenChange(false); - const templatesPath = formatTemplatesPath(team?.url); + const templatesPath = formatTemplatesPath(team.url); if (data.folderId) { void navigate(`${templatesPath}/f/${data.folderId}`); diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index 511ce04db..aa557132b 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -30,7 +30,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type TokenDeleteDialogProps = { token: Pick; @@ -42,7 +42,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [isOpen, setIsOpen] = useState(false); diff --git a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx index f8c5c94d2..ce1109322 100644 --- a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form'; import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema'; +import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -34,11 +34,11 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox'; -const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true }); +const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true }); type TCreateWebhookFormSchema = z.infer; @@ -50,7 +50,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [open, setOpen] = useState(false); @@ -78,7 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr eventTriggers, secret, webhookUrl, - teamId: team?.id, + teamId: team.id, }); setOpen(false); diff --git a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index 5842f4fb7..6fb369577 100644 --- a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -30,7 +30,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type WebhookDeleteDialogProps = { webhook: Pick; @@ -42,7 +42,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [open, setOpen] = useState(false); @@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr const onSubmit = async () => { try { - await deleteWebhook({ id: webhook.id, teamId: team?.id }); + await deleteWebhook({ id: webhook.id, teamId: team.id }); toast({ title: _(msg`Webhook deleted`), diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index aa780385c..09f6a91d2 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -332,7 +332,7 @@ export const EmbedDirectTemplateClientPage = ({ {/* Widget */}
diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index e125b2b4a..ef2eedc1c 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -290,7 +290,7 @@ export const EmbedSignDocumentClientPage = ({ {/* Widget */}
diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx new file mode 100644 index 000000000..01476a5ab --- /dev/null +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx @@ -0,0 +1,182 @@ +import { Trans } from '@lingui/react/macro'; +import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client'; +import { ArrowRight, EyeIcon, XCircle } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import type { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Progress } from '@documenso/ui/primitives/progress'; + +// Get the return type from getRecipientByToken +type RecipientWithFields = Awaited>; + +interface DocumentEnvelope { + document: DocumentAndSender; + recipient: RecipientWithFields; +} + +interface MultiSignDocumentListProps { + envelopes: DocumentEnvelope[]; + onDocumentSelect: (document: DocumentEnvelope['document']) => void; +} + +export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSignDocumentListProps) { + // Calculate progress + const completedDocuments = envelopes.filter( + (envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED, + ); + const totalDocuments = envelopes.length; + const progressPercentage = (completedDocuments.length / totalDocuments) * 100; + + // Find next document to sign (first one that's not signed and not rejected) + const nextDocumentToSign = envelopes.find( + (envelope) => + envelope.recipient.signingStatus !== SigningStatus.SIGNED && + envelope.recipient.signingStatus !== SigningStatus.REJECTED, + ); + + const allDocumentsCompleted = completedDocuments.length === totalDocuments; + + const hasAssistantOrCcRecipient = envelopes.some( + (envelope) => + envelope.recipient.role === RecipientRole.ASSISTANT || + envelope.recipient.role === RecipientRole.CC, + ); + + function handleView(doc: DocumentEnvelope['document']) { + onDocumentSelect(doc); + } + + function handleNextDocument() { + if (nextDocumentToSign) { + onDocumentSelect(nextDocumentToSign.document); + } + } + + if (hasAssistantOrCcRecipient) { + return ( +
+
+ +
+ +

+ It looks like we ran into an issue! +

+ +

+ + One of the documents in the current bundle has a signing role that is not compatible + with the current signing experience. + +

+ +

+ + Assistants and Copy roles are currently not compatible with the multi-sign experience. + +

+ +

+ Please contact the site owner for further assistance. +

+
+ ); + } + + return ( +
+

+ Sign Documents +

+ +

+ + You have been requested to sign the following documents. Review each document carefully + and complete the signing process. + +

+ + {/* Progress Section */} +
+
+ + Progress + + + {completedDocuments.length} of {totalDocuments} completed + +
+ +
+ +
+
+ +
+ {envelopes.map((envelope) => ( +
+ + {envelope.document.title} + + + {match(envelope.recipient) + .with({ signingStatus: SigningStatus.SIGNED }, () => ( + + Completed + + )) + .with({ signingStatus: SigningStatus.REJECTED }, () => ( + + Rejected + + )) + .with({ readStatus: ReadStatus.OPENED }, () => ( + + Viewed + + )) + .otherwise(() => null)} + + +
+ ))} +
+ + {/* Next Document Button */} + {!allDocumentsCompleted && nextDocumentToSign && ( +
+ +
+ )} + + {allDocumentsCompleted && ( + + + All documents have been completed! + + + Thank you for completing the signing process. + + + )} +
+ ); +} diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx new file mode 100644 index 000000000..37abdcce3 --- /dev/null +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -0,0 +1,396 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client'; +import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react'; +import { P, match } from 'ts-pattern'; + +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import PDFViewer from '@documenso/ui/primitives/pdf-viewer'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; +import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; +import { EmbedDocumentFields } from '../embed-document-fields'; + +interface MultiSignDocumentSigningViewProps { + token: string; + recipientId: number; + onBack: () => void; + onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void; + onDocumentRejected?: (data: { + token: string; + documentId: number; + recipientId: number; + reason: string; + }) => void; + onDocumentError?: () => void; + onDocumentReady?: () => void; + isNameLocked?: boolean; + allowDocumentRejection?: boolean; +} + +export const MultiSignDocumentSigningView = ({ + token, + recipientId, + onBack, + onDocumentCompleted, + onDocumentRejected, + onDocumentError, + onDocumentReady, + isNameLocked = false, + allowDocumentRejection = false, +}: MultiSignDocumentSigningViewProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { fullName, email, signature, setFullName, setSignature } = + useRequiredDocumentSigningContext(); + + const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); + + const [isExpanded, setIsExpanded] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); + + const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery( + { token }, + { + staleTime: 0, + }, + ); + + const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation(); + const { mutateAsync: removeSignedFieldWithToken } = + trpc.field.removeSignedFieldWithToken.useMutation(); + + const { mutateAsync: completeDocumentWithToken } = + trpc.recipient.completeDocumentWithToken.useMutation(); + + const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE); + + const [pendingFields, completedFields] = [ + document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? + [], + document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ?? + [], + ]; + + const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? []; + + const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => { + try { + await signFieldWithToken(payload); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => { + try { + await removeSignedFieldWithToken(payload); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + } + }; + + const onDocumentComplete = async () => { + try { + setIsSubmitting(true); + + await completeDocumentWithToken({ + documentId: document!.id, + token, + }); + + onBack(); + + onDocumentCompleted?.({ + token, + documentId: document!.id, + recipientId, + }); + } catch (err) { + onDocumentError?.(); + + toast({ + title: 'Error', + description: 'Failed to complete the document. Please try again.', + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + const onNextFieldClick = () => { + setShowPendingFieldTooltip(true); + + setIsExpanded(false); + }; + + const onRejected = (reason: string) => { + if (onDocumentRejected && document) { + onDocumentRejected({ + token, + documentId: document.id, + recipientId, + reason, + }); + } + }; + + return ( +
+
+ {match({ isLoading, document }) + .with({ isLoading: true }, () => ( +
+
+ +

+ Loading document... +

+
+
+ )) + .with({ isLoading: false, document: undefined }, () => ( +
+

+ Failed to load document +

+
+ )) + .with({ document: P.nonNullable }, ({ document }) => ( + <> +
+
+

{document.title}

+
+ + +
+ + {allowDocumentRejection && ( +
+ +
+ )} + +
+
+ { + setHasDocumentLoaded(true); + onDocumentReady?.(); + }} + /> +
+ + {/* Widget */} + {document.status !== DocumentStatus.COMPLETED && ( +
+
+ {/* Header */} +
+
+

+ Sign document +

+ + +
+
+ +
+

+ Sign the document to complete the process. +

+ +
+
+ + {/* Form */} +
+
+ { + <> +
+ + + !isNameLocked && setFullName(e.target.value)} + /> +
+ +
+ + + +
+ + {hasSignatureField && ( +
+ + + setSignature(v ?? '')} + typedSignatureEnabled={ + document.documentMeta?.typedSignatureEnabled + } + uploadSignatureEnabled={ + document.documentMeta?.uploadSignatureEnabled + } + drawSignatureEnabled={ + document.documentMeta?.drawSignatureEnabled + } + /> +
+ )} + + } +
+
+ +
+ +
+ {uninsertedFields.length > 0 ? ( + + ) : ( + + )} +
+
+
+ )} +
+ + {hasDocumentLoaded && ( + + {showPendingFieldTooltip && pendingFields.length > 0 && ( + + Click to insert field + + )} + + )} + + {/* Fields */} + {hasDocumentLoaded && ( + + )} + + {/* Completed fields */} + {document.status !== DocumentStatus.COMPLETED && ( + + )} + + )) + .otherwise(() => null)} +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/avatar-image.tsx b/apps/remix/app/components/forms/avatar-image.tsx index 852063287..0d6dd4ddf 100644 --- a/apps/remix/app/components/forms/avatar-image.tsx +++ b/apps/remix/app/components/forms/avatar-image.tsx @@ -6,7 +6,6 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { ErrorCode, useDropzone } from 'react-dropzone'; import { useForm } from 'react-hook-form'; -import { useRevalidator } from 'react-router'; import { match } from 'ts-pattern'; import { z } from 'zod'; @@ -29,8 +28,6 @@ import { } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; - export const ZAvatarImageFormSchema = z.object({ bytes: z.string().nullish(), }); @@ -39,29 +36,44 @@ export type TAvatarImageFormSchema = z.infer; export type AvatarImageFormProps = { className?: string; + team?: { + id: number; + name: string; + avatarImageId: string | null; + }; + organisation?: { + id: string; + name: string; + avatarImageId: string | null; + }; }; -export const AvatarImageForm = ({ className }: AvatarImageFormProps) => { +export const AvatarImageForm = ({ className, team, organisation }: AvatarImageFormProps) => { const { user, refreshSession } = useSession(); const { _ } = useLingui(); const { toast } = useToast(); - const { revalidate } = useRevalidator(); - - const team = useOptionalCurrentTeam(); const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation(); - const initials = extractInitials(team?.name || user.name || ''); + const initials = extractInitials(team?.name || organisation?.name || user.name || ''); const hasAvatarImage = useMemo(() => { if (team) { return team.avatarImageId !== null; } - return user.avatarImageId !== null; - }, [team, user.avatarImageId]); + if (organisation) { + return organisation.avatarImageId !== null; + } - const avatarImageId = team ? team.avatarImageId : user.avatarImageId; + return user.avatarImageId !== null; + }, [team, organisation, user.avatarImageId]); + + const avatarImageId = team + ? team.avatarImageId + : organisation + ? organisation.avatarImageId + : user.avatarImageId; const form = useForm({ values: { @@ -100,7 +112,8 @@ export const AvatarImageForm = ({ className }: AvatarImageFormProps) => { try { await setProfileImage({ bytes: data.bytes, - teamId: team?.id, + teamId: team?.id ?? null, + organisationId: organisation?.id ?? null, }); await refreshSession(); diff --git a/apps/remix/app/components/forms/team-branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx similarity index 64% rename from apps/remix/app/components/forms/team-branding-preferences-form.tsx rename to apps/remix/app/components/forms/branding-preferences-form.tsx index 5cc519960..85355a7b1 100644 --- a/apps/remix/app/components/forms/team-branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -1,17 +1,14 @@ import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; +import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; -import type { Team, TeamGlobalSettings } from '@prisma/client'; +import type { TeamGlobalSettings } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; -import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,15 +20,20 @@ import { FormLabel, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { Switch } from '@documenso/ui/primitives/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; import { Textarea } from '@documenso/ui/primitives/textarea'; -import { useToast } from '@documenso/ui/primitives/use-toast'; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; -const ZTeamBrandingPreferencesFormSchema = z.object({ - brandingEnabled: z.boolean(), +const ZBrandingPreferencesFormSchema = z.object({ + brandingEnabled: z.boolean().nullable(), brandingLogo: z .instanceof(File) .refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') @@ -44,76 +46,45 @@ const ZTeamBrandingPreferencesFormSchema = z.object({ brandingCompanyDetails: z.string().max(500).optional(), }); -type TTeamBrandingPreferencesFormSchema = z.infer; +export type TBrandingPreferencesFormSchema = z.infer; -export type TeamBrandingPreferencesFormProps = { - team: Team; - settings?: TeamGlobalSettings | null; +type SettingsSubset = Pick< + TeamGlobalSettings, + 'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails' +>; + +export type BrandingPreferencesFormProps = { + canInherit?: boolean; + settings: SettingsSubset; + onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise; + context: 'Team' | 'Organisation'; }; -export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) { - const { _ } = useLingui(); - const { toast } = useToast(); +export function BrandingPreferencesForm({ + canInherit = false, + settings, + onFormSubmit, + context, +}: BrandingPreferencesFormProps) { + const { t } = useLingui(); const [previewUrl, setPreviewUrl] = useState(''); const [hasLoadedPreview, setHasLoadedPreview] = useState(false); - const { mutateAsync: updateTeamBrandingSettings } = - trpc.team.updateTeamBrandingSettings.useMutation(); - - const form = useForm({ + const form = useForm({ defaultValues: { - brandingEnabled: settings?.brandingEnabled ?? false, - brandingUrl: settings?.brandingUrl ?? '', + brandingEnabled: settings.brandingEnabled ?? null, + brandingUrl: settings.brandingUrl ?? '', brandingLogo: undefined, - brandingCompanyDetails: settings?.brandingCompanyDetails ?? '', + brandingCompanyDetails: settings.brandingCompanyDetails ?? '', }, - resolver: zodResolver(ZTeamBrandingPreferencesFormSchema), + resolver: zodResolver(ZBrandingPreferencesFormSchema), }); const isBrandingEnabled = form.watch('brandingEnabled'); - const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => { - try { - const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; - - let uploadedBrandingLogo = settings?.brandingLogo; - - if (brandingLogo) { - uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); - } - - if (brandingLogo === null) { - uploadedBrandingLogo = ''; - } - - await updateTeamBrandingSettings({ - teamId: team.id, - settings: { - brandingEnabled, - brandingLogo: uploadedBrandingLogo, - brandingUrl, - brandingCompanyDetails, - }, - }); - - toast({ - title: _(msg`Branding preferences updated`), - description: _(msg`Your branding preferences have been updated`), - }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _( - msg`We were unable to update your branding preferences at this time, please try again later`, - ), - variant: 'destructive', - }); - } - }; - useEffect(() => { - if (settings?.brandingLogo) { + if (settings.brandingLogo) { const file = JSON.parse(settings.brandingLogo); if ('type' in file && 'data' in file) { @@ -129,7 +100,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref } setHasLoadedPreview(true); - }, [settings?.brandingLogo]); + }, [settings.brandingLogo]); // Cleanup ObjectURL on unmount or when previewUrl changes useEffect(() => { @@ -142,45 +113,72 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref return (
- -
+ +
( - Enable Custom Branding + + Enable Custom Branding + -
- - - -
+ + + - Enable custom branding for all documents in this team. + {context === 'Team' ? ( + Enable custom branding for all documents in this team + ) : ( + Enable custom branding for all documents in this organisation + )}
)} />
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
} ( - Branding Logo + + Branding Logo +
@@ -192,7 +190,8 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref /> ) : (
- Please upload a logo + Please upload a logo + {!hasLoadedPreview && (
@@ -253,6 +252,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref Upload your brand logo (max 5MB, JPG, PNG, or WebP) + + {canInherit && ( + + {'. '} + Leave blank to inherit from the organisation. + + )}
@@ -264,7 +270,9 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref name="brandingUrl" render={({ field }) => ( - Brand Website + + Brand Website + Your brand website URL + + {canInherit && ( + + {'. '} + Leave blank to inherit from the organisation. + + )} )} @@ -287,11 +302,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref name="brandingCompanyDetails" render={({ field }) => ( - Brand Details + + Brand Details +