mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 12:32:34 +10:00
Compare commits
1 Commits
v1.12.9
...
exp/effect
| Author | SHA1 | Date | |
|---|---|---|---|
| db4d33d039 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -56,7 +56,4 @@ logs.json
|
||||
|
||||
# claude
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
||||
# agents
|
||||
.specs
|
||||
CLAUDE.md
|
||||
57
AGENTS.md
57
AGENTS.md
@ -1,57 +0,0 @@
|
||||
# Agent Guidelines for Documenso
|
||||
|
||||
## Build/Test/Lint Commands
|
||||
|
||||
- `npm run build` - Build all packages
|
||||
- `npm run lint` - Lint all packages
|
||||
- `npm run lint:fix` - Auto-fix linting issues
|
||||
- `npm run test:e2e` - Run E2E tests with Playwright
|
||||
- `npm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode
|
||||
- `npm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI
|
||||
- `npm run format` - Format code with Prettier
|
||||
- `npm run dev` - Start development server for Remix app
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- Use TypeScript for all code; prefer `type` over `interface`
|
||||
- Use functional components with `const Component = () => {}`
|
||||
- Never use classes; prefer functional/declarative patterns
|
||||
- Use descriptive variable names with auxiliary verbs (isLoading, hasError)
|
||||
- Directory names: lowercase with dashes (auth-wizard)
|
||||
- Use named exports for components
|
||||
- Never use 'use client' directive
|
||||
- Never use 1-line if statements
|
||||
- Structure files: exported component, subcomponents, helpers, static content, types
|
||||
|
||||
## Error Handling & Validation
|
||||
|
||||
- Use custom AppError class when throwing errors
|
||||
- When catching errors on the frontend use `const error = AppError.parse(error)` to get the error code
|
||||
- Use early returns and guard clauses
|
||||
- Use Zod for form validation and react-hook-form for forms
|
||||
- Use error boundaries for unexpected errors
|
||||
|
||||
## UI & Styling
|
||||
|
||||
- Use Shadcn UI, Radix, and Tailwind CSS with mobile-first approach
|
||||
- Use `<Form>` `<FormItem>` elements with fieldset having `:disabled` attribute when loading
|
||||
- Use Lucide icons with longhand names (HomeIcon vs Home)
|
||||
|
||||
## TRPC Routes
|
||||
|
||||
- Each route in own file: `routers/teams/create-team.ts`
|
||||
- Associated types file: `routers/teams/create-team.types.ts`
|
||||
- Request/response schemas: `Z[RouteName]RequestSchema`, `Z[RouteName]ResponseSchema`
|
||||
- Only use GET and POST methods in OpenAPI meta
|
||||
- Deconstruct input argument on its own line
|
||||
- Prefer route names such as get/getMany/find/create/update/delete
|
||||
- "create" routes request schema should have the ID and data in the top level
|
||||
- "update" routes request schema should have the ID in the top level and the data in a nested "data" object
|
||||
|
||||
## Translations & Remix
|
||||
|
||||
- Use `<Trans>string</Trans>` for JSX translations from `@lingui/react/macro`
|
||||
- Use `t\`string\`` macro for TypeScript translations
|
||||
- Use `(params: Route.Params)` and `(loaderData: Route.LoaderData)` for routes
|
||||
- Directly return data from loaders, don't use `json()`
|
||||
- Use `superLoaderJson` when sending complex data through loaders such as dates or prisma decimals
|
||||
@ -214,6 +214,8 @@ For detailed instructions on how to configure and run the Docker container, plea
|
||||
|
||||
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||
|
||||
> Please note that the below deployment methods are for v0.9, we will update these to v1.0 once it has been released.
|
||||
|
||||
### Fetch, configure, and build
|
||||
|
||||
First, clone the code from Github:
|
||||
@ -256,7 +258,7 @@ npm run start
|
||||
|
||||
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
|
||||
|
||||
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
|
||||
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/web` folder.
|
||||
|
||||
### Run as a service
|
||||
|
||||
@ -306,7 +308,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on
|
||||
|
||||
### Support IPv6
|
||||
|
||||
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command
|
||||
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command
|
||||
|
||||
For local docker run
|
||||
|
||||
|
||||
21
SIGNING.md
21
SIGNING.md
@ -10,26 +10,15 @@ For the digital signature of your documents you need a signing certificate in .p
|
||||
|
||||
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
||||
|
||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid.
|
||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid.
|
||||
|
||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this:
|
||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this:
|
||||
|
||||
```bash
|
||||
# Set certificate password securely (won't appear in command history)
|
||||
read -s -p "Enter certificate password: " CERT_PASS
|
||||
echo
|
||||
|
||||
# Create the p12 certificate using the environment variable
|
||||
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \
|
||||
-password env:CERT_PASS \
|
||||
-keypbe PBE-SHA1-3DES \
|
||||
-certpbe PBE-SHA1-3DES \
|
||||
-macalg sha1
|
||||
```
|
||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||
|
||||
4. **IMPORTANT**: A certificate password is required to prevent signing failures. Make sure to use a strong password (minimum 4 characters) when prompted. Certificates without passwords will cause "Failed to get private key bags" errors during document signing.
|
||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
||||
|
||||
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ The translation files are organized into folders represented by their respective
|
||||
Each PO file contains translations which look like this:
|
||||
|
||||
```po
|
||||
#: apps/remix/app/(signing)/sign/[token]/no-longer-available.tsx:61
|
||||
#: apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61
|
||||
msgid "Want to send slick signing links like this one? <0>Check out Documenso.</0>"
|
||||
msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso.</0>"
|
||||
```
|
||||
|
||||
@ -54,7 +54,7 @@ Install the project dependencies as follows:
|
||||
|
||||
```bash
|
||||
npm i
|
||||
npm run build
|
||||
npm run build:web
|
||||
npm run prisma:migrate-deploy
|
||||
```
|
||||
|
||||
@ -69,7 +69,7 @@ npm run start
|
||||
This will start the server on `localhost:3000`. Any reverse proxy can handle the front end and SSL termination.
|
||||
|
||||
<Callout type="info">
|
||||
If you want to run with another port than `3000`, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
|
||||
If you want to run with another port than `3000`, you can start the application with `next -p <ANY PORT>` from the `apps/web` folder.
|
||||
</Callout>
|
||||
|
||||
</Steps>
|
||||
@ -119,89 +119,16 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||
```
|
||||
|
||||
### Set Up Your Signing Certificate
|
||||
### Update the Volume Binding
|
||||
|
||||
<Callout type="warning">
|
||||
This is the most common source of issues for self-hosters. Please follow these steps carefully.
|
||||
</Callout>
|
||||
The `cert.p12` file is required to sign and encrypt documents, so you must provide your key file. Update the volume binding in the `compose.yml` file to point to your key file:
|
||||
|
||||
The `cert.p12` file is required to sign and encrypt documents. You have three options:
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
|
||||
```
|
||||
|
||||
#### Option A: Generate Certificate Inside Container (Recommended)
|
||||
|
||||
This method avoids file permission issues by creating the certificate directly inside the Docker container:
|
||||
|
||||
1. Start your containers:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. Set certificate password securely and generate certificate inside the container:
|
||||
|
||||
```bash
|
||||
# Set certificate password securely (won't appear in command history)
|
||||
read -s -p "Enter certificate password: " CERT_PASS
|
||||
echo
|
||||
|
||||
# Generate certificate inside container using environment variable
|
||||
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /tmp/private.key \
|
||||
-out /tmp/certificate.crt \
|
||||
-subj '/C=US/ST=State/L=City/O=Organization/CN=localhost' && \
|
||||
openssl pkcs12 -export -out /app/certs/cert.p12 \
|
||||
-inkey /tmp/private.key -in /tmp/certificate.crt \
|
||||
-passout env:CERT_PASS && \
|
||||
rm /tmp/private.key /tmp/certificate.crt
|
||||
"
|
||||
```
|
||||
|
||||
3. Add the certificate passphrase to your `.env` file:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE="your_password_here"
|
||||
```
|
||||
|
||||
4. Restart the container to apply changes:
|
||||
```bash
|
||||
docker-compose restart documenso
|
||||
```
|
||||
|
||||
#### Option B: Use an Existing Certificate File
|
||||
|
||||
If you have an existing `.p12` certificate file:
|
||||
|
||||
1. **Place your certificate file** in an accessible location on your host system
|
||||
2. **Set proper permissions:**
|
||||
|
||||
```bash
|
||||
# Make sure the certificate is readable
|
||||
chmod 644 /path/to/your/cert.p12
|
||||
|
||||
# For Docker, ensure proper ownership
|
||||
chown 1001:1001 /path/to/your/cert.p12
|
||||
```
|
||||
|
||||
3. **Update the volume binding** in the `compose.yml` file:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
|
||||
```
|
||||
|
||||
4. **Add certificate configuration** to your `.env` file:
|
||||
```bash
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=your_certificate_password
|
||||
```
|
||||
|
||||
<Callout type="warning">
|
||||
Your certificate MUST have a password. Certificates without passwords will cause "Failed to get
|
||||
private key bags" errors.
|
||||
</Callout>
|
||||
|
||||
After setting up your certificate, save the `compose.yml` file and run the following command to start the containers:
|
||||
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env up -d
|
||||
@ -322,7 +249,7 @@ After=network.target
|
||||
Environment=PATH=/path/to/your/node/binaries
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/documenso/apps/remix
|
||||
WorkingDirectory=/var/www/documenso/apps/web
|
||||
ExecStart=/usr/bin/next start -p 3500
|
||||
TimeoutSec=15
|
||||
Restart=always
|
||||
|
||||
@ -19,13 +19,13 @@ device, and other FDA-regulated industries.
|
||||
- [x] User Access Management
|
||||
- [x] Quality Assurance Documentation
|
||||
|
||||
## SOC 2
|
||||
## SOC/ SOC II
|
||||
|
||||
<Callout type="info" emoji="✅">
|
||||
Status: [Compliant](https://documen.so/trust)
|
||||
<Callout type="warning" emoji="⏳">
|
||||
Status: [Planned](https://github.com/documenso/backlog/issues/24)
|
||||
</Callout>
|
||||
|
||||
SOC 2 is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
||||
SOC II is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
||||
and data privacy in cloud and IT service organizations, established by the American Institute of Certified
|
||||
Public Accountants (AICPA).
|
||||
|
||||
@ -34,9 +34,9 @@ Public Accountants (AICPA).
|
||||
<Callout type="warning" emoji="⏳">
|
||||
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
||||
</Callout>
|
||||
ISO 27001 is an international standard for managing information security, specifying requirements
|
||||
for establishing, implementing, maintaining, and continually improving an information security
|
||||
management system (ISMS).
|
||||
ISO 27001 is an international standard for managing information security, specifying requirements for
|
||||
establishing, implementing, maintaining, and continually improving an information security management
|
||||
system (ISMS).
|
||||
|
||||
### HIPAA
|
||||
|
||||
|
||||
@ -18,11 +18,6 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
|
||||
|
||||
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
||||
|
||||
<Callout type="info">
|
||||
The maximum file size for uploaded documents is 150MB in production. In staging, the limit is
|
||||
50MB.
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
||||
|
||||
@ -3,6 +3,5 @@
|
||||
"members": "Members",
|
||||
"groups": "Groups",
|
||||
"teams": "Teams",
|
||||
"sso": "SSO",
|
||||
"billing": "Billing"
|
||||
}
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"index": "Configuration",
|
||||
"microsoft-entra-id": "Microsoft Entra ID"
|
||||
}
|
||||
@ -1,149 +0,0 @@
|
||||
---
|
||||
title: SSO Portal
|
||||
description: Learn how to set up a custom SSO login portal for your organisation.
|
||||
---
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Organisation SSO Portal
|
||||
|
||||
The SSO Portal provides a dedicated login URL for your organisation that integrates with any OIDC compliant identity provider. This feature provides:
|
||||
|
||||
- **Single Sign-On**: Access Documenso using your own authentication system
|
||||
- **Automatic onboarding**: New users will be automatically added to your organisation when they sign in through the portal
|
||||
- **Delegated account management**: Your organisation has full control over the users who sign in through the portal
|
||||
|
||||
<Callout type="warning">
|
||||
Anyone who signs in through your portal will be added to your organisation as a member.
|
||||
</Callout>
|
||||
|
||||
## Getting Started
|
||||
|
||||
To set up the SSO Portal, you need to be an organisation owner, admin, or manager.
|
||||
|
||||
<Callout type="info">
|
||||
**Enterprise Only**: This feature is only available to Enterprise customers.
|
||||
</Callout>
|
||||
|
||||
<Steps>
|
||||
|
||||
### Access Organisation SSO Settings
|
||||
|
||||

|
||||
|
||||
### Configure SSO Portal
|
||||
|
||||
See the [Microsoft Entra ID](/users/organisations/sso/microsoft-entra-id) guide to find the values for the following fields.
|
||||
|
||||
#### Issuer URL
|
||||
|
||||
Enter the OpenID discovery endpoint URL for your provider. Here are some common examples:
|
||||
|
||||
- **Google Workspace**: `https://accounts.google.com/.well-known/openid-configuration`
|
||||
- **Microsoft Entra ID**: `https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration`
|
||||
- **Okta**: `https://{your-domain}.okta.com/.well-known/openid-configuration`
|
||||
- **Auth0**: `https://{your-domain}.auth0.com/.well-known/openid-configuration`
|
||||
|
||||
#### Client Credentials
|
||||
|
||||
Enter the client ID and client secret provided by your identity provider:
|
||||
|
||||
- **Client ID**: The unique identifier for your application
|
||||
- **Client Secret**: The secret key for authenticating your application
|
||||
|
||||
#### Default Organisation Role
|
||||
|
||||
Select the default Organisation role that new users will receive when they first sign in through the portal.
|
||||
|
||||
#### Allowed Email Domains
|
||||
|
||||
Specify which email domains are allowed to sign in through your SSO portal. Separate domains with spaces:
|
||||
|
||||
```
|
||||
your-domain.com another-domain.com
|
||||
```
|
||||
|
||||
Leave this field empty to allow all domains.
|
||||
|
||||
### Configure Your Identity Provider
|
||||
|
||||
You'll need to configure your identity provider with the following information:
|
||||
|
||||
- Redirect URI
|
||||
- Scopes
|
||||
|
||||
These values are found at the top of the page.
|
||||
|
||||
### Save Configuration
|
||||
|
||||
Toggle the "Enable SSO portal" switch to activate the feature for your organisation.
|
||||
|
||||
Click "Update" to save your SSO portal configuration. The portal will be activated once all required fields are completed.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Testing Your SSO Portal
|
||||
|
||||
Once configured, you can test your SSO portal by:
|
||||
|
||||
1. Navigating to your portal URL found at the top of the organisation SSO portal settings page
|
||||
2. Sign in with a test account from your configured domain
|
||||
3. Verifying that the user is properly provisioned with the correct organisation role
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Reduce Friction
|
||||
|
||||
Create a custom subdomain for your organisation's SSO portal. For example, you can create a subdomain like `documenso.your-organisation.com` which redirects to the portal link.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
Please note that anyone who signs in through your portal will be added to your organisation as a member.
|
||||
|
||||
- **Domain Restrictions**: Use allowed domains to prevent unauthorized access
|
||||
- **Role Assignment**: Carefully consider the default organisation role for new users
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**"Invalid issuer URL"**
|
||||
|
||||
- Verify the issuer URL is correct and accessible
|
||||
- Ensure the URL follows the OpenID Connect discovery format
|
||||
|
||||
**"Client authentication failed"**
|
||||
|
||||
- Check that your client ID and client secret are correct
|
||||
- Verify that your application is properly registered with your identity provider
|
||||
|
||||
**"User not provisioned"**
|
||||
|
||||
- Check that the user's email domain is in the allowed domains list
|
||||
- Verify the default organisation role is set correctly
|
||||
|
||||
**"Redirect URI mismatch"**
|
||||
|
||||
- Ensure the redirect URI in Documenso matches exactly what's configured in your identity provider
|
||||
- Check for any trailing slashes or protocol mismatches
|
||||
|
||||
### Getting Help
|
||||
|
||||
If you encounter issues with your SSO portal configuration:
|
||||
|
||||
1. Review your identity provider's documentation for OpenID Connect setup
|
||||
2. Check the Documenso logs for detailed error messages
|
||||
3. Contact your identity provider's support for provider-specific issues
|
||||
|
||||
<Callout type="info">
|
||||
For additional support for SSO Portal configuration, contact our support team at
|
||||
support@documenso.com.
|
||||
</Callout>
|
||||
|
||||
## Identity Provider Guides
|
||||
|
||||
For detailed setup instructions for specific identity providers:
|
||||
|
||||
- [Microsoft Entra ID](/users/organisations/sso/microsoft-entra-id) - Complete guide for Azure AD configuration
|
||||
@ -1,76 +0,0 @@
|
||||
---
|
||||
title: Microsoft Entra ID
|
||||
description: Learn how to configure Microsoft Entra ID (Azure AD) for your organisation's SSO portal.
|
||||
---
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Microsoft Entra ID Configuration
|
||||
|
||||
Microsoft Entra ID (formerly Azure Active Directory) is a popular identity provider for enterprise SSO. This guide will walk you through creating an app registration and configuring it for use with your Documenso SSO portal.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Access to Microsoft Entra ID (Azure AD) admin center
|
||||
- Access to your Documenso organisation as an administrator or manager
|
||||
|
||||
<Callout type="warning">Each user in your Azure AD will need an email associated with it.</Callout>
|
||||
|
||||
## Creating an App Registration
|
||||
|
||||
<Steps>
|
||||
|
||||
### Access Azure Portal
|
||||
|
||||
1. Navigate to the Azure Portal
|
||||
2. Sign in with your Microsoft Entra ID administrator account
|
||||
3. Search for "Azure Active Directory" or "Microsoft Entra ID" in the search bar
|
||||
4. Click on "Microsoft Entra ID" from the results
|
||||
|
||||
### Create App Registration
|
||||
|
||||
1. In the left sidebar, click on "App registrations"
|
||||
2. Click the "New registration" button
|
||||
|
||||
### Configure App Registration
|
||||
|
||||
Fill in the registration form with the following details:
|
||||
|
||||
- **Name**: Your preferred name (e.g. `Documenso SSO Portal`)
|
||||
- **Supported account types**: Choose based on your needs
|
||||
- **Redirect URI (Web)**: Found in the Documenso SSO portal settings page
|
||||
|
||||
Click "Register" to create the app registration.
|
||||
|
||||
### Get Client ID
|
||||
|
||||
After registration, you'll be taken to the app's overview page. The **Application (client) ID** is displayed prominently - this is your Client ID for Documenso.
|
||||
|
||||
### Create Client Secret
|
||||
|
||||
1. In the left sidebar, click on "Certificates & secrets"
|
||||
2. Click "New client secret"
|
||||
3. Add a description (e.g., "Documenso SSO Secret")
|
||||
4. Choose an expiration period (recommended 12-24 months)
|
||||
5. Click "Add"
|
||||
|
||||
Make sure you copy the "Secret value", not the "Secret ID", you won't be able to access it again after you leave the page.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Getting Your OpenID Configuration URL
|
||||
|
||||
1. In the Azure portal, go to "Microsoft Entra ID"
|
||||
2. Click on "Overview" in the left sidebar
|
||||
3. Click the "Endpoints" in the horizontal tab
|
||||
4. Copy the "OpenID Connect metadata document" value
|
||||
|
||||
## Configure Documenso SSO Portal
|
||||
|
||||
Now you have all the information needed to configure your Documenso SSO portal:
|
||||
|
||||
- **Issuer URL**: The "OpenID Connect metadata document" value from the previous step
|
||||
- **Client ID**: The Application (client) ID from your app registration
|
||||
- **Client Secret**: The secret value you copied during creation
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 175 KiB |
@ -34,7 +34,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
|
||||
trpc.admin.document.delete.useMutation();
|
||||
trpc.admin.deleteDocument.useMutation();
|
||||
|
||||
const handleDeleteDocument = async () => {
|
||||
try {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@ -96,16 +96,17 @@ export const AdminOrganisationCreateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
|
||||
@ -3,12 +3,12 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserDeleteDialogProps = {
|
||||
className?: string;
|
||||
user: TGetUserResponse;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
|
||||
@ -35,7 +35,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
||||
trpc.admin.user.delete.useMutation();
|
||||
trpc.admin.deleteUser.useMutation();
|
||||
|
||||
const onDeleteAccount = async () => {
|
||||
try {
|
||||
|
||||
@ -3,11 +3,11 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserDisableDialogProps = {
|
||||
className?: string;
|
||||
userToDisable: TGetUserResponse;
|
||||
userToDisable: User;
|
||||
};
|
||||
|
||||
export const AdminUserDisableDialog = ({
|
||||
@ -37,7 +37,7 @@ export const AdminUserDisableDialog = ({
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: disableUser, isPending: isDisablingUser } =
|
||||
trpc.admin.user.disable.useMutation();
|
||||
trpc.admin.disableUser.useMutation();
|
||||
|
||||
const onDisableAccount = async () => {
|
||||
try {
|
||||
|
||||
@ -3,11 +3,11 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserEnableDialogProps = {
|
||||
className?: string;
|
||||
userToEnable: TGetUserResponse;
|
||||
userToEnable: User;
|
||||
};
|
||||
|
||||
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
|
||||
@ -34,7 +34,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: enableUser, isPending: isEnablingUser } =
|
||||
trpc.admin.user.enable.useMutation();
|
||||
trpc.admin.enableUser.useMutation();
|
||||
|
||||
const onEnableAccount = async () => {
|
||||
try {
|
||||
|
||||
@ -3,12 +3,12 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserResetTwoFactorDialogProps = {
|
||||
className?: string;
|
||||
user: TGetUserResponse;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const AdminUserResetTwoFactorDialog = ({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -49,7 +49,7 @@ export const DocumentDeleteDialog = ({
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
|
||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
|
||||
onSuccess: async () => {
|
||||
void refreshLimits();
|
||||
|
||||
@ -73,20 +73,23 @@ export const DocumentDeleteDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInputValue('');
|
||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||
}
|
||||
}, [open, status]);
|
||||
|
||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
setInputValue('');
|
||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||
}
|
||||
if (!isPending) {
|
||||
onOpenChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
@ -36,12 +36,11 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
||||
{
|
||||
documentId: id,
|
||||
},
|
||||
{
|
||||
queryHash: `document-duplicate-dialog-${id}`,
|
||||
enabled: open === true,
|
||||
},
|
||||
);
|
||||
@ -56,7 +55,7 @@ export const DocumentDuplicateDialog = ({
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||
trpcReact.document.duplicate.useMutation({
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: async ({ documentId }) => {
|
||||
toast({
|
||||
title: _(msg`Document Duplicated`),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -83,15 +83,6 @@ export const DocumentMoveToFolderDialog = ({
|
||||
|
||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId });
|
||||
}
|
||||
}, [open, currentFolderId, form]);
|
||||
|
||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||
try {
|
||||
await moveDocumentToFolder({
|
||||
@ -145,12 +136,22 @@ export const DocumentMoveToFolderDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId });
|
||||
}
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
@ -71,7 +71,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
|
||||
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
|
||||
|
||||
const form = useForm<TResendDocumentFormSchema>({
|
||||
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||
@ -85,11 +85,6 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const selectedRecipients = useWatch({
|
||||
control: form.control,
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
@ -156,7 +151,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full border border-neutral-400"
|
||||
className="h-5 w-5 rounded-full"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
@ -187,13 +182,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
className="flex-1"
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={isSubmitting || selectedRecipients.length === 0}
|
||||
>
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
<Trans>Send reminder</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
@ -80,14 +80,15 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreateFolderOpen) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isCreateFolderOpen, form]);
|
||||
setIsCreateFolderOpen(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@ -92,14 +90,15 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isOpen]);
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@ -97,12 +97,13 @@ export const FolderMoveDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
}
|
||||
}, [isOpen, form]);
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
// Filter out the current folder, only show folders of the same type, and filter by search term
|
||||
const filteredFolders = foldersData?.filter(
|
||||
@ -113,7 +114,7 @@ export const FolderMoveDialog = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@ -71,15 +69,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (folder) {
|
||||
form.reset({
|
||||
name: folder.name,
|
||||
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
||||
});
|
||||
}
|
||||
}, [folder, form]);
|
||||
|
||||
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
||||
if (!folder) {
|
||||
return;
|
||||
@ -110,8 +99,18 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value && folder) {
|
||||
form.reset({
|
||||
name: folder.name,
|
||||
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
||||
});
|
||||
}
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
@ -76,7 +76,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
|
||||
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(actionSearchParam === 'add-organisation');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||
@ -91,6 +91,19 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
enabled: IS_BILLING_ENABLED(),
|
||||
});
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
if (actionSearchParam === 'add-organisation') {
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}
|
||||
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
||||
try {
|
||||
const response = await createOrganisation({
|
||||
@ -126,17 +139,6 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (actionSearchParam === 'add-organisation') {
|
||||
setOpen(true);
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}, [actionSearchParam, open]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
const isIndividualPlan = (priceId: string) => {
|
||||
return (
|
||||
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
|
||||
@ -145,11 +147,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
@ -314,13 +312,16 @@ const BillingPlanForm = ({
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [plans]);
|
||||
}, [plans, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === '' && !canCreateFreeOrganisation) {
|
||||
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
|
||||
if (value === '' && !canCreateFreeOrganisation && dynamicPlans.length > 0) {
|
||||
const defaultValue = dynamicPlans[0][billingPeriod]?.id ?? '';
|
||||
if (defaultValue) {
|
||||
onChange(defaultValue);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
}, [canCreateFreeOrganisation, dynamicPlans, billingPeriod, onChange, value]);
|
||||
|
||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
||||
const plan = dynamicPlans.find(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -93,14 +93,19 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
setOpen(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@ -73,13 +73,6 @@ export const OrganisationEmailCreateDialog = ({
|
||||
const { mutateAsync: createOrganisationEmail, isPending } =
|
||||
trpc.enterprise.organisation.email.create.useMutation();
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
|
||||
try {
|
||||
await createOrganisationEmail({
|
||||
@ -114,8 +107,17 @@ export const OrganisationEmailCreateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
if (!isPending) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
|
||||
@ -36,6 +36,12 @@ export const OrganisationEmailDeleteDialog = ({
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!isDeleting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
const { mutateAsync: deleteEmail, isPending: isDeleting } =
|
||||
trpc.enterprise.organisation.email.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
@ -58,7 +64,7 @@ export const OrganisationEmailDeleteDialog = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@ -75,14 +75,6 @@ export const OrganisationEmailDomainCreateDialog = ({
|
||||
const { mutateAsync: createOrganisationEmail } =
|
||||
trpc.enterprise.organisation.emailDomain.create.useMutation();
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setStep('domain');
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
|
||||
try {
|
||||
const { records } = await createOrganisationEmail({
|
||||
@ -118,12 +110,18 @@ export const OrganisationEmailDomainCreateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setStep('domain');
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@ -87,23 +87,20 @@ export const OrganisationEmailUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset({
|
||||
emailName: organisationEmail.emailName,
|
||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
form.reset({
|
||||
emailName: organisationEmail.emailName,
|
||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||
});
|
||||
}, [open, form]);
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@ -117,16 +117,17 @@ export const OrganisationGroupCreateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -193,13 +193,6 @@ export const OrganisationMemberInviteDialog = ({
|
||||
return 'form';
|
||||
}, [fullOrganisation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setInvitationType('INDIVIDUAL');
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) {
|
||||
return;
|
||||
@ -267,12 +260,18 @@ export const OrganisationMemberInviteDialog = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setInvitationType('INDIVIDUAL');
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -106,32 +106,27 @@ export const OrganisationMemberUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
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',
|
||||
});
|
||||
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',
|
||||
});
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -65,9 +65,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
||||
});
|
||||
|
||||
const { mutateAsync: createPasskeyRegistrationOptions, isPending } =
|
||||
trpc.auth.passkey.createRegistrationOptions.useMutation();
|
||||
trpc.auth.createPasskeyRegistrationOptions.useMutation();
|
||||
|
||||
const { mutateAsync: createPasskey } = trpc.auth.passkey.create.useMutation();
|
||||
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||
setFormError(null);
|
||||
@ -120,24 +120,21 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
||||
return passkeyName;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const handleDialogOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||
|
||||
form.reset({
|
||||
passkeyName: defaultPasskeyName,
|
||||
});
|
||||
|
||||
setFormError(null);
|
||||
}
|
||||
}, [open, form]);
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary" loading={isPending}>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -66,14 +66,14 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
const shouldOpenDialog = actionSearchParam === 'add-team';
|
||||
const [open, setOpen] = useState(shouldOpenDialog);
|
||||
|
||||
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.id,
|
||||
});
|
||||
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateTeamFormSchema),
|
||||
defaultValues: {
|
||||
@ -85,6 +85,18 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
|
||||
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
if (shouldOpenDialog) {
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
||||
try {
|
||||
await createTeam({
|
||||
@ -150,23 +162,8 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
return 'form';
|
||||
}, [fullOrganisation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionSearchParam === 'add-team') {
|
||||
setOpen(true);
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}, [actionSearchParam, open]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -114,14 +114,17 @@ export const TeamDeleteDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -103,18 +103,17 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" loading={isPending} className="bg-background">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -92,18 +92,17 @@ export const TeamEmailUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="bg-background">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
@ -107,7 +107,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
handleClose();
|
||||
} catch {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
@ -117,17 +117,23 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}, [open, form]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||
// Since it would be annoying to redo the whole process.
|
||||
>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -106,22 +106,17 @@ export const TeamGroupUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!form.formState.isSubmitting) {
|
||||
if (value) {
|
||||
form.reset();
|
||||
}
|
||||
setOpen(value);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
@ -119,20 +119,17 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
}
|
||||
}, [open, form]);
|
||||
// Disable automatic onOpenChange events to prevent dialog from closing if user 'accidentally' clicks the overlay.
|
||||
// Since it would be annoying to redo the whole process, we handle open state manually
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||
// Since it would be annoying to redo the whole process.
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||
<Trans>Add members</Trans>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -106,30 +106,25 @@ export const TeamMemberUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset();
|
||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||
setOpen(false);
|
||||
toast({
|
||||
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -186,16 +186,20 @@ export const TemplateDirectLinkDialog = ({
|
||||
const isLoading =
|
||||
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
||||
|
||||
useEffect(() => {
|
||||
resetCreateTemplateDirectLink();
|
||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||
setSelectedRecipientId(null);
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!isLoading) {
|
||||
if (value) {
|
||||
resetCreateTemplateDirectLink();
|
||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||
setSelectedRecipientId(null);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
onOpenChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<fieldset disabled={isLoading} className="relative">
|
||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||
{match({ token, currentStep })
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -85,15 +85,6 @@ export function TemplateMoveToFolderDialog({
|
||||
|
||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId ?? null });
|
||||
}
|
||||
}, [isOpen, currentFolderId, form]);
|
||||
|
||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||
try {
|
||||
await moveTemplateToFolder({
|
||||
@ -137,12 +128,22 @@ export function TemplateMoveToFolderDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId ?? null });
|
||||
}
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data?.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
||||
<Dialog {...props} open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||
isTemplateRecipientEmailPlaceholder,
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
@ -45,22 +46,50 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
// Display exactly which rows are duplicates.
|
||||
.superRefine((items, ctx) => {
|
||||
const uniqueEmails = new Map<string, number>();
|
||||
|
||||
for (const [index, recipients] of items.recipients.entries()) {
|
||||
const email = recipients.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: ['recipients', index, 'email'],
|
||||
});
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', firstFoundIndex, 'email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
@ -174,14 +203,17 @@ export function TemplateUseDialog({
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (form.formState.isSubmitting) return;
|
||||
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
setOpen(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" className="bg-background">
|
||||
@ -249,7 +281,14 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={
|
||||
isTemplateRecipientEmailPlaceholder(field.value)
|
||||
? ''
|
||||
: _(msg`Email`)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -270,7 +309,6 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
aria-label="Name"
|
||||
placeholder={recipients[index].name || _(msg`Name`)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
||||
|
||||
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
|
||||
|
||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({
|
||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
||||
onSuccess() {
|
||||
onDelete?.();
|
||||
},
|
||||
@ -95,17 +95,17 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isOpen, form]);
|
||||
if (!form.formState.isSubmitting) {
|
||||
setIsOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
||||
>
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild={true}>
|
||||
{children ?? (
|
||||
<Button className="mr-4" variant="destructive">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -88,14 +88,17 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? (
|
||||
<Button className="mr-4" variant="destructive">
|
||||
|
||||
@ -172,8 +172,6 @@ export const ConfigureFieldsView = ({
|
||||
name: 'fields',
|
||||
});
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
@ -542,9 +540,7 @@ export const ConfigureFieldsView = ({
|
||||
<div>
|
||||
<PDFViewer documentData={normalizedDocumentData} />
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex(
|
||||
(r) => r.id === field.recipientId,
|
||||
|
||||
@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
token,
|
||||
updatedAt,
|
||||
documentData,
|
||||
recipient: _recipient,
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
@ -91,12 +91,8 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
localFields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||
|
||||
@ -347,34 +343,19 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -461,9 +442,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
|
||||
@ -50,10 +50,8 @@ export const EmbedDocumentFields = ({
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: EmbedDocumentFieldsProps) => {
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
return (
|
||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
|
||||
@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
useState(false);
|
||||
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
@ -106,8 +106,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
@ -118,8 +116,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
@ -309,36 +305,19 @@ export const EmbedSignDocumentClientPage = ({
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={
|
||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||
}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -486,9 +465,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
export const EmbedDocumentWaitingForTurn = () => {
|
||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.parent && !hasPostedMessage) {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-waiting-for-turn',
|
||||
@ -15,13 +13,7 @@ export const EmbedDocumentWaitingForTurn = () => {
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasPostedMessage(true);
|
||||
}, [hasPostedMessage]);
|
||||
|
||||
if (!hasPostedMessage) {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
|
||||
@ -92,8 +92,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
[],
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||
|
||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||
@ -359,9 +357,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
</div>
|
||||
|
||||
{hasDocumentLoaded && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip
|
||||
key={pendingFields[0].id}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -136,18 +136,19 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
enable2FAForm.reset();
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
|
||||
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||
setRecoveryCodes(null);
|
||||
if (!open) {
|
||||
enable2FAForm.reset();
|
||||
if (recoveryCodes && recoveryCodes.length > 0) {
|
||||
setRecoveryCodes(null);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild={true}>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
|
||||
@ -3,11 +3,10 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
@ -87,10 +86,8 @@ export const DocumentPreferencesForm = ({
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
||||
|
||||
const placeholderEmail = user.email ?? 'user@example.com';
|
||||
|
||||
@ -334,7 +331,7 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPersonalLayoutMode && !isPersonalOrganisation && (
|
||||
{!isPersonalLayoutMode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
@ -114,11 +114,20 @@ export const SignInForm = ({
|
||||
}, [returnTo]);
|
||||
|
||||
const { mutateAsync: createPasskeySigninOptions } =
|
||||
trpc.auth.passkey.createSigninOptions.useMutation();
|
||||
trpc.auth.createPasskeySigninOptions.useMutation();
|
||||
|
||||
const emailFromHash = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const hash = window.location.hash.slice(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
return params.get('email');
|
||||
}, []);
|
||||
|
||||
const form = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
email: initialEmail ?? '',
|
||||
email: emailFromHash ?? initialEmail ?? '',
|
||||
password: '',
|
||||
totpCode: '',
|
||||
backupCode: '',
|
||||
@ -287,18 +296,6 @@ export const SignInForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
const email = params.get('email');
|
||||
|
||||
if (email) {
|
||||
form.setValue('email', email);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
@ -84,10 +84,19 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const emailFromHash = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const hash = window.location.hash.slice(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
return params.get('email');
|
||||
}, []);
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
email: initialEmail ?? '',
|
||||
email: emailFromHash ?? initialEmail ?? '',
|
||||
password: '',
|
||||
signature: '',
|
||||
},
|
||||
@ -162,18 +171,6 @@ export const SignUpForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
const email = params.get('email');
|
||||
|
||||
if (email) {
|
||||
form.setValue('email', email);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-center gap-x-12', className)}>
|
||||
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
|
||||
|
||||
@ -13,7 +13,7 @@ import type { z } from 'zod';
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
|
||||
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -47,7 +47,7 @@ export const EXPIRATION_DATES = {
|
||||
ONE_YEAR: msg`12 months`,
|
||||
} as const;
|
||||
|
||||
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
|
||||
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.pick({
|
||||
tokenName: true,
|
||||
expirationDate: true,
|
||||
});
|
||||
@ -75,7 +75,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
|
||||
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
||||
|
||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
|
||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
|
||||
onSuccess(data) {
|
||||
setNewlyCreatedToken(data);
|
||||
},
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
@ -64,7 +64,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||
trpcReact.document.search.useQuery(
|
||||
trpcReact.document.searchDocuments.useQuery(
|
||||
{
|
||||
query: search,
|
||||
},
|
||||
|
||||
@ -79,8 +79,6 @@ export const DirectTemplateSigningForm = ({
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
|
||||
|
||||
const fieldsRequiringValidation = useMemo(() => {
|
||||
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||
}, [localFields]);
|
||||
@ -223,9 +221,7 @@ export const DirectTemplateSigningForm = ({
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
@ -417,11 +413,11 @@ export const DirectTemplateSigningForm = ({
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={async () => handleSubmit()}
|
||||
onSignatureComplete={handleSubmit}
|
||||
documentTitle={template.title}
|
||||
fields={localFields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
recipient={directRecipient}
|
||||
role={directRecipient.role}
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
@ -1,312 +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 { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
type FormStep = 'method-selection' | 'code-input';
|
||||
type TwoFactorMethod = 'email' | 'authenticator';
|
||||
|
||||
const ZAccessAuth2FAFormSchema = z.object({
|
||||
token: z.string().length(6, { message: 'Token must be 6 characters long' }),
|
||||
});
|
||||
|
||||
type TAccessAuth2FAFormSchema = z.infer<typeof ZAccessAuth2FAFormSchema>;
|
||||
|
||||
export type AccessAuth2FAFormProps = {
|
||||
onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void;
|
||||
token: string;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => {
|
||||
const [step, setStep] = useState<FormStep>('method-selection');
|
||||
const [selectedMethod, setSelectedMethod] = useState<TwoFactorMethod | null>(null);
|
||||
|
||||
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
|
||||
const [millisecondsRemaining, setMillisecondsRemaining] = useState<number | null>(null);
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { user } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } =
|
||||
trpc.document.accessAuth.request2FAEmail.useMutation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZAccessAuth2FAFormSchema),
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
});
|
||||
|
||||
const hasAuthenticatorEnabled = user?.twoFactorEnabled === true;
|
||||
|
||||
const onMethodSelect = async (method: TwoFactorMethod) => {
|
||||
setSelectedMethod(method);
|
||||
|
||||
if (method === 'email') {
|
||||
try {
|
||||
const result = await request2FAEmail({
|
||||
token: token,
|
||||
});
|
||||
|
||||
setExpiresAt(result.expiresAt);
|
||||
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||
|
||||
setStep('code-input');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStep('code-input');
|
||||
};
|
||||
|
||||
const onFormSubmit = (data: TAccessAuth2FAFormSchema) => {
|
||||
if (!selectedMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the auth options for the completion attempt
|
||||
const accessAuthOptions: TRecipientAccessAuth = {
|
||||
type: 'TWO_FACTOR_AUTH',
|
||||
token: data.token, // Just the user's code - backend will validate using method type
|
||||
method: selectedMethod,
|
||||
};
|
||||
|
||||
onSubmit(accessAuthOptions);
|
||||
};
|
||||
|
||||
const onGoBack = () => {
|
||||
setStep('method-selection');
|
||||
setSelectedMethod(null);
|
||||
setExpiresAt(null);
|
||||
setMillisecondsRemaining(null);
|
||||
};
|
||||
|
||||
const onResendEmail = async () => {
|
||||
if (selectedMethod !== 'email') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await request2FAEmail({
|
||||
token: token,
|
||||
});
|
||||
|
||||
setExpiresAt(result.expiresAt);
|
||||
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (expiresAt) {
|
||||
setMillisecondsRemaining(expiresAt.valueOf() - Date.now());
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [expiresAt]);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
{step === 'method-selection' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Choose verification method</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Please select how you'd like to receive your verification code.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" padding="tight" className="text-sm">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||
onClick={async () => onMethodSelect('email')}
|
||||
disabled={isRequesting2FAEmail}
|
||||
>
|
||||
<MailIcon className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
<Trans>Email verification</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans>We'll send a 6-digit code to your email</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{hasAuthenticatorEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||
onClick={async () => onMethodSelect('authenticator')}
|
||||
disabled={isRequesting2FAEmail}
|
||||
>
|
||||
<KeyIcon className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
<Trans>Authenticator app</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans>Use your authenticator app to generate a code</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'code-input' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onGoBack}>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Enter verification code</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{selectedMethod === 'email' ? (
|
||||
<Trans>
|
||||
We've sent a 6-digit verification code to your email. Please enter it below to
|
||||
complete the document.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Please open your authenticator app and enter the 6-digit code for this document.
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="access-auth-2fa-form"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 items-center justify-center">
|
||||
<PinInput
|
||||
{...field}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
pattern="^\d+$"
|
||||
aria-label="2FA code"
|
||||
containerClassName="h-12 justify-center"
|
||||
>
|
||||
<PinInputGroup>
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={0} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={1} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={2} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={3} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={4} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={5} />
|
||||
</PinInputGroup>
|
||||
</PinInput>
|
||||
|
||||
{expiresAt && millisecondsRemaining !== null && (
|
||||
<div
|
||||
className={cn('text-muted-foreground mt-2 text-center text-sm', {
|
||||
'text-destructive': millisecondsRemaining <= 0,
|
||||
})}
|
||||
>
|
||||
<Trans>
|
||||
Expires in{' '}
|
||||
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
|
||||
'mm:ss',
|
||||
)}
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Button
|
||||
type="submit"
|
||||
form="access-auth-2fa-form"
|
||||
className="w-full"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={isRequesting2FAEmail || form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Verify & Complete</Trans>
|
||||
</Button>
|
||||
|
||||
{selectedMethod === 'email' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onResendEmail}
|
||||
loading={isRequesting2FAEmail}
|
||||
>
|
||||
<Trans>Resend code</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -77,7 +77,7 @@ export const DocumentSigningAuthPasskey = ({
|
||||
});
|
||||
|
||||
const { mutateAsync: createPasskeyAuthenticationOptions } =
|
||||
trpc.auth.passkey.createAuthenticationOptions.useMutation();
|
||||
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
|
||||
|
||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
[documentAuthOptions, recipient],
|
||||
);
|
||||
|
||||
const passkeyQuery = trpc.auth.passkey.find.useQuery(
|
||||
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
|
||||
{
|
||||
perPage: MAXIMUM_PASSKEYS,
|
||||
},
|
||||
|
||||
@ -9,7 +9,6 @@ import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
import { AUTO_SIGNABLE_FIELD_TYPES } from '@documenso/lib/constants/autosign';
|
||||
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
@ -61,12 +60,6 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
const { email, fullName } = useRequiredDocumentSigningContext();
|
||||
const { derivedRecipientActionAuth } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const autoSignableFields = fields.filter((field) => {
|
||||
if (field.inserted) {
|
||||
return false;
|
||||
@ -95,6 +88,14 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
(actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(() => {
|
||||
return actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD;
|
||||
});
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const onSubmit = async () => {
|
||||
const results = await Promise.allSettled(
|
||||
autoSignableFields.map(async (field) => {
|
||||
@ -152,12 +153,6 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
await revalidate();
|
||||
};
|
||||
|
||||
unsafe_useEffectOnce(() => {
|
||||
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@ -2,17 +2,12 @@ import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
type TRecipientAccessAuth,
|
||||
ZDocumentAccessAuthSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -32,21 +27,15 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningCompleteDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
documentTitle: string;
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: (
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
) => void | Promise<void>;
|
||||
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
||||
role: RecipientRole;
|
||||
disabled?: boolean;
|
||||
allowDictateNextSigner?: boolean;
|
||||
defaultNextSigner?: {
|
||||
@ -58,7 +47,6 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
|
||||
});
|
||||
|
||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
@ -69,7 +57,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
recipient,
|
||||
role,
|
||||
disabled = false,
|
||||
allowDictateNextSigner = false,
|
||||
defaultNextSigner,
|
||||
@ -77,11 +65,6 @@ export const DocumentSigningCompleteDialog = ({
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||
|
||||
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
|
||||
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
||||
|
||||
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const form = useForm<TNextSignerFormSchema>({
|
||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||
defaultValues: {
|
||||
@ -92,11 +75,6 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||
|
||||
const completionRequires2FA = useMemo(
|
||||
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
|
||||
[derivedRecipientAccessAuth],
|
||||
);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (form.formState.isSubmitting || !isComplete) {
|
||||
return;
|
||||
@ -115,43 +93,16 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||
try {
|
||||
// Check if 2FA is required
|
||||
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||
setShowTwoFactorForm(true);
|
||||
return;
|
||||
if (allowDictateNextSigner && data.name && data.email) {
|
||||
await onSignatureComplete({ name: data.name, email: data.email });
|
||||
} else {
|
||||
await onSignatureComplete();
|
||||
}
|
||||
|
||||
const nextSigner =
|
||||
allowDictateNextSigner && data.name && data.email
|
||||
? { name: data.name, email: data.email }
|
||||
: undefined;
|
||||
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions);
|
||||
} catch (error) {
|
||||
const err = AppError.parseError(error);
|
||||
|
||||
if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) {
|
||||
// This was a 2FA validation failure - show the 2FA dialog again with error
|
||||
form.setValue('accessAuthOptions', undefined);
|
||||
|
||||
setTwoFactorValidationError('Invalid verification code. Please try again.');
|
||||
setShowTwoFactorForm(true);
|
||||
|
||||
return;
|
||||
}
|
||||
console.error('Error completing signature:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => {
|
||||
form.setValue('accessAuthOptions', validatedAuthOptions);
|
||||
|
||||
setShowTwoFactorForm(false);
|
||||
setTwoFactorValidationError(null);
|
||||
|
||||
// Now trigger the form submission with auth options
|
||||
void form.handleSubmit(onFormSubmit)();
|
||||
};
|
||||
|
||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||
|
||||
return (
|
||||
@ -165,7 +116,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
loading={isSubmitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
{match({ isComplete, role: recipient.role })
|
||||
{match({ isComplete, role })
|
||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||
@ -177,194 +128,184 @@ export const DocumentSigningCompleteDialog = ({
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
{!showTwoFactorForm && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
<DialogTitle>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
<DialogTitle>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
|
||||
{allowDictateNextSigner && (
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</p>
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||
>
|
||||
<Trans>Update Recipient</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{allowDictateNextSigner && (
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</p>
|
||||
|
||||
{isEditingNextSigner && (
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's name"
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||
>
|
||||
<Trans>Update Recipient</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isEditingNextSigner && (
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's name"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DocumentSigningDisclosure className="mt-4" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DocumentSigningDisclosure className="mt-4" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!isComplete || !isNextSignerValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
<DialogFooter className="mt-4">
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{showTwoFactorForm && (
|
||||
<AccessAuth2FAForm
|
||||
token={recipient.token}
|
||||
error={twoFactorValidationError}
|
||||
onSubmit={onTwoFactorFormSubmit}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!isComplete || !isNextSignerValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -7,11 +7,14 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -31,33 +34,29 @@ export type DocumentSigningFormProps = {
|
||||
document: DocumentAndSender;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
redirectUrl?: string | null;
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
completeDocument: (options: {
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
nextSigner?: { email: string; name: string };
|
||||
}) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
fieldsValidated: () => void;
|
||||
nextRecipient?: RecipientWithFields;
|
||||
};
|
||||
|
||||
export const DocumentSigningForm = ({
|
||||
document,
|
||||
recipient,
|
||||
fields,
|
||||
redirectUrl,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
setSelectedSignerId,
|
||||
completeDocument,
|
||||
isSubmitting,
|
||||
fieldsValidated,
|
||||
nextRecipient,
|
||||
}: DocumentSigningFormProps) => {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
@ -67,12 +66,21 @@ export const DocumentSigningForm = ({
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocumentWithToken,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||
defaultValues: {
|
||||
selectedSignerId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
const isSubmitting = isPending || isSuccess;
|
||||
|
||||
const fieldsRequiringValidation = useMemo(
|
||||
() => fields.filter(isFieldUnsignedAndRequired),
|
||||
[fields],
|
||||
@ -88,9 +96,9 @@ export const DocumentSigningForm = ({
|
||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||
}, [fieldsRequiringValidation, recipient]);
|
||||
|
||||
const localFieldsValidated = () => {
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
fieldsValidated();
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
};
|
||||
|
||||
const onAssistantFormSubmit = () => {
|
||||
@ -105,7 +113,7 @@ export const DocumentSigningForm = ({
|
||||
setIsAssistantSubmitting(true);
|
||||
|
||||
try {
|
||||
await completeDocument({ nextSigner });
|
||||
await completeDocument(undefined, nextSigner);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
@ -118,6 +126,55 @@ export const DocumentSigningForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const completeDocument = async (
|
||||
authOptions?: TRecipientActionAuth,
|
||||
nextSigner?: { email: string; name: string },
|
||||
) => {
|
||||
const payload = {
|
||||
token: recipient.token,
|
||||
documentId: document.id,
|
||||
authOptions,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
};
|
||||
|
||||
await completeDocumentWithToken(payload);
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: document.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
}
|
||||
};
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (
|
||||
!document.documentMeta?.signingOrder ||
|
||||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||
// Sort by signingOrder first (nulls last), then by id
|
||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||
if (a.signingOrder === null) return 1;
|
||||
if (b.signingOrder === null) return -1;
|
||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||
return a.signingOrder - b.signingOrder;
|
||||
});
|
||||
|
||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||
? sortedRecipients[currentIndex + 1]
|
||||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
@ -148,11 +205,11 @@ export const DocumentSigningForm = ({
|
||||
isSubmitting={isSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={localFieldsValidated}
|
||||
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||
completeDocument({ nextSigner, accessAuthOptions })
|
||||
}
|
||||
recipient={recipient}
|
||||
fieldsValidated={fieldsValidated}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
@ -307,15 +364,12 @@ export const DocumentSigningForm = ({
|
||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={localFieldsValidated}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||
completeDocument({
|
||||
accessAuthOptions,
|
||||
nextSigner,
|
||||
})
|
||||
}
|
||||
recipient={recipient}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
@ -21,11 +18,8 @@ import {
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -46,8 +40,6 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||
|
||||
export type DocumentSigningPageViewProps = {
|
||||
@ -71,64 +63,9 @@ export const DocumentSigningPageView = ({
|
||||
}: DocumentSigningPageViewProps) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const hasAuthenticator = authUser?.twoFactorEnabled
|
||||
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||
: false;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocumentWithToken,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
const isSubmitting = isPending || isSuccess;
|
||||
|
||||
const fieldsRequiringValidation = useMemo(
|
||||
() => fields.filter(isFieldUnsignedAndRequired),
|
||||
[fields],
|
||||
);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
};
|
||||
|
||||
const completeDocument = async (options: {
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
nextSigner?: { email: string; name: string };
|
||||
}) => {
|
||||
const { accessAuthOptions, nextSigner } = options;
|
||||
|
||||
const payload = {
|
||||
token: recipient.token,
|
||||
documentId: document.id,
|
||||
accessAuthOptions,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
};
|
||||
|
||||
await completeDocumentWithToken(payload);
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: document.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (documentMeta?.redirectUrl) {
|
||||
window.location.href = documentMeta.redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
}
|
||||
};
|
||||
|
||||
let senderName = document.user.name ?? '';
|
||||
let senderEmail = `(${document.user.email})`;
|
||||
|
||||
@ -141,42 +78,9 @@ export const DocumentSigningPageView = ({
|
||||
const targetSigner =
|
||||
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||
// Sort by signingOrder first (nulls last), then by id
|
||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||
if (a.signingOrder === null) return 1;
|
||||
if (b.signingOrder === null) return -1;
|
||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||
return a.signingOrder - b.signingOrder;
|
||||
});
|
||||
|
||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||
? sortedRecipients[currentIndex + 1]
|
||||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
const hasPendingFields = pendingFields.length > 0;
|
||||
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||
{document.team.teamGlobalSettings.brandingEnabled &&
|
||||
document.team.teamGlobalSettings.brandingLogo && (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${document.teamId}`}
|
||||
alt={`${document.team.name}'s Logo`}
|
||||
className="mb-4 h-12 w-12 md:mb-2"
|
||||
/>
|
||||
)}
|
||||
<h1
|
||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
@ -259,55 +163,19 @@ export const DocumentSigningPageView = ({
|
||||
.otherwise(() => null)}
|
||||
</h3>
|
||||
|
||||
{match({ hasPendingFields, isExpanded, role: recipient.role })
|
||||
.with(
|
||||
{
|
||||
hasPendingFields: false,
|
||||
role: P.not(RecipientRole.ASSISTANT),
|
||||
isExpanded: false,
|
||||
},
|
||||
() => (
|
||||
<div className="md:hidden">
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) =>
|
||||
completeDocument({ nextSigner })
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
.with({ isExpanded: true }, () => (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
@ -336,13 +204,10 @@ export const DocumentSigningPageView = ({
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
completeDocument={completeDocument}
|
||||
isSubmitting={isSubmitting}
|
||||
fieldsValidated={fieldsValidated}
|
||||
nextRecipient={nextRecipient}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -359,9 +224,7 @@ export const DocumentSigningPageView = ({
|
||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||
)}
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields
|
||||
.filter(
|
||||
(field) =>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -80,20 +80,16 @@ export const DocumentSigningTextField = ({
|
||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
||||
const shouldAutoSignField =
|
||||
(!field.inserted && parsedFieldMeta?.text) ||
|
||||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
|
||||
const shouldAutoSignField = useMemo(
|
||||
() =>
|
||||
(!field.inserted && parsedFieldMeta?.text) ||
|
||||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly),
|
||||
[field.inserted, parsedFieldMeta?.text, parsedFieldMeta?.readOnly],
|
||||
);
|
||||
|
||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCustomTextModal) {
|
||||
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||
setErrors(initialErrors);
|
||||
}
|
||||
}, [showCustomTextModal]);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value;
|
||||
setLocalCustomText(text);
|
||||
@ -216,14 +212,12 @@ export const DocumentSigningTextField = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
|
||||
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
||||
|
||||
@ -318,7 +312,8 @@ export const DocumentSigningTextField = ({
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowCustomTextModal(false);
|
||||
setLocalCustomText('');
|
||||
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||
setErrors(initialErrors);
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@ -21,7 +21,7 @@ export const DocumentAuditLogDownloadButton = ({
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { mutateAsync: downloadAuditLogs, isPending } =
|
||||
trpc.document.auditLog.download.useMutation();
|
||||
trpc.document.downloadAuditLogs.useMutation();
|
||||
|
||||
const onDownloadAuditLogsClick = async () => {
|
||||
try {
|
||||
|
||||
@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -49,7 +49,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
|
||||
const { quota, remaining, refreshLimits } = useLimits();
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
|
||||
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||
|
||||
@ -108,51 +108,15 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
if (!fileRejections.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||
const { file, errors } = fileRejections[0];
|
||||
|
||||
if (!errors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorNodes = errors.map((error, index) => (
|
||||
<span key={index} className="block">
|
||||
{match(error.code)
|
||||
.with(ErrorCode.FileTooLarge, () => (
|
||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
||||
))
|
||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
||||
.with(ErrorCode.TooManyFiles, () => (
|
||||
<Trans>Only one file can be uploaded at a time</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Unknown error</Trans>
|
||||
))}
|
||||
</span>
|
||||
));
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<span className="font-medium">
|
||||
{file.name} <Trans>couldn't be uploaded:</Trans>
|
||||
</span>
|
||||
{errorNodes}
|
||||
</>
|
||||
);
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title: _(msg`Upload failed`),
|
||||
description,
|
||||
title: _(msg`Your document failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
@ -165,8 +129,8 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
void onFileDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
onDropRejected: (fileRejections) => {
|
||||
onFileDropRejected(fileRejections);
|
||||
onDropRejected: () => {
|
||||
void onFileDropRejected();
|
||||
},
|
||||
noClick: true,
|
||||
noDragEventsBubbling: true,
|
||||
|
||||
@ -59,22 +59,23 @@ export const DocumentEditForm = ({
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: document, refetch: refetchDocument } = trpc.document.get.useQuery(
|
||||
{
|
||||
documentId: initialDocument.id,
|
||||
},
|
||||
{
|
||||
initialData: initialDocument,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
},
|
||||
);
|
||||
const { data: document, refetch: refetchDocument } =
|
||||
trpc.document.getDocumentWithDetailsById.useQuery(
|
||||
{
|
||||
documentId: initialDocument.id,
|
||||
},
|
||||
{
|
||||
initialData: initialDocument,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
},
|
||||
);
|
||||
|
||||
const { recipients, fields } = document;
|
||||
|
||||
const { mutateAsync: updateDocument } = trpc.document.update.useMutation({
|
||||
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.document.get.setData(
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
documentId: initialDocument.id,
|
||||
},
|
||||
@ -83,10 +84,23 @@ export const DocumentEditForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: setSigningOrderForDocument } =
|
||||
trpc.document.setSigningOrderForDocument.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
documentId: initialDocument.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: ({ fields: newFields }) => {
|
||||
utils.document.get.setData(
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
documentId: initialDocument.id,
|
||||
},
|
||||
@ -98,7 +112,7 @@ export const DocumentEditForm = ({
|
||||
const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: ({ recipients: newRecipients }) => {
|
||||
utils.document.get.setData(
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
documentId: initialDocument.id,
|
||||
},
|
||||
@ -107,10 +121,10 @@ export const DocumentEditForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: sendDocument } = trpc.document.distribute.useMutation({
|
||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.document.get.setData(
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
documentId: initialDocument.id,
|
||||
},
|
||||
@ -159,37 +173,34 @@ export const DocumentEditForm = ({
|
||||
return initialStep;
|
||||
});
|
||||
|
||||
const saveSettingsData = async (data: TAddSettingsFormSchema) => {
|
||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
return updateDocument({
|
||||
documentId: document.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
timezone,
|
||||
dateFormat,
|
||||
redirectUrl,
|
||||
language: isValidLanguageCode(language) ? language : undefined,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||
try {
|
||||
await saveSettingsData(data);
|
||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
await updateDocument({
|
||||
documentId: document.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
timezone,
|
||||
dateFormat,
|
||||
redirectUrl,
|
||||
language: isValidLanguageCode(language) ? language : undefined,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
},
|
||||
});
|
||||
|
||||
setStep('signers');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -202,50 +213,18 @@ export const DocumentEditForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => {
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
await saveSettingsData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while auto-saving the document settings.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const saveSignersData = async (data: TAddSignersFormSchema) => {
|
||||
return Promise.all([
|
||||
updateDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||
await Promise.all([
|
||||
setSigningOrderForDocument({
|
||||
documentId: document.id,
|
||||
signingOrder: data.signingOrder,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
setRecipients({
|
||||
documentId: document.id,
|
||||
recipients: data.signers.map((signer) => ({
|
||||
...signer,
|
||||
// Explicitly set to null to indicate we want to remove auth if required.
|
||||
actionAuth: signer.actionAuth ?? [],
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// For autosave, we need to return the recipients response for form state sync
|
||||
const [, recipientsResponse] = await Promise.all([
|
||||
updateDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||
signingOrder: data.signingOrder,
|
||||
},
|
||||
}),
|
||||
|
||||
@ -259,24 +238,6 @@ export const DocumentEditForm = ({
|
||||
}),
|
||||
]);
|
||||
|
||||
return recipientsResponse;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while adding signers.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err; // Re-throw so the autosave hook can handle the error
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
await saveSignersData(data);
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -289,16 +250,12 @@ export const DocumentEditForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
||||
return addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
await saveFieldsData(data);
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
// Clear all field data from localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
@ -320,60 +277,24 @@ export const DocumentEditForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onAddFieldsFormAutoSave = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
await saveFieldsData(data);
|
||||
// Don't clear localStorage on auto-save, only on explicit submit
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while auto-saving the fields.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const saveSubjectData = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||
data.meta;
|
||||
|
||||
return updateDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailId,
|
||||
emailReplyTo,
|
||||
emailSettings: emailSettings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||
data.meta;
|
||||
|
||||
return sendDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailId,
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
emailSettings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
try {
|
||||
await sendDocumentWithSubject(data);
|
||||
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||
data.meta;
|
||||
|
||||
if (data.meta.distributionMethod === DocumentDistributionMethod.EMAIL) {
|
||||
try {
|
||||
await sendDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailId,
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
emailSettings: emailSettings,
|
||||
},
|
||||
});
|
||||
|
||||
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
|
||||
toast({
|
||||
title: _(msg`Document sent`),
|
||||
description: _(msg`Your document has been sent successfully.`),
|
||||
@ -401,21 +322,6 @@ export const DocumentEditForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSubjectFormAutoSave = async (data: TAddSubjectFormSchema) => {
|
||||
try {
|
||||
// Save form data without sending the document
|
||||
await saveSubjectData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while auto-saving the subject form.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
/**
|
||||
@ -461,28 +367,25 @@ export const DocumentEditForm = ({
|
||||
fields={fields}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
onAutoSave={onAddSettingsFormAutoSave}
|
||||
/>
|
||||
|
||||
<AddSignersFormPartial
|
||||
key={document.id}
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
signingOrder={document.documentMeta?.signingOrder}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
fields={fields}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
onAutoSave={onAddSignersFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddFieldsFormPartial
|
||||
key={document.id}
|
||||
key={fields.length}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
onAutoSave={onAddFieldsFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
teamId={team.id}
|
||||
/>
|
||||
@ -494,7 +397,6 @@ export const DocumentEditForm = ({
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
onAutoSave={onAddSubjectFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
</Stepper>
|
||||
|
||||
@ -42,7 +42,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.get.query(
|
||||
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||
{
|
||||
documentId: document.id,
|
||||
},
|
||||
|
||||
@ -71,7 +71,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.get.query(
|
||||
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||
{
|
||||
documentId: document.id,
|
||||
},
|
||||
@ -100,7 +100,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
|
||||
const onDownloadOriginalClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.get.query(
|
||||
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||
{
|
||||
documentId: document.id,
|
||||
},
|
||||
|
||||
@ -32,7 +32,7 @@ export const DocumentPageViewRecentActivity = ({
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = trpc.document.auditLog.find.useInfiniteQuery(
|
||||
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
||||
{
|
||||
documentId,
|
||||
filterForRecentActivity: true,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -26,16 +26,13 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
|
||||
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams],
|
||||
[searchParams, setSearchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
const currentQuery = searchParams?.get('query') ?? '';
|
||||
if (currentQuery !== debouncedSearchTerm) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
@ -52,7 +52,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (organisation.subscription && remaining.documents === 0) {
|
||||
|
||||
@ -54,7 +54,7 @@ export const FolderCard = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
|
||||
<Link to={formatPath()} key={folder.id}>
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
|
||||
@ -28,7 +28,7 @@ export const LegacyFieldWarningPopover = ({
|
||||
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
|
||||
trpc.template.updateTemplate.useMutation();
|
||||
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
|
||||
trpc.document.update.useMutation();
|
||||
trpc.document.updateDocument.useMutation();
|
||||
|
||||
const onUpdateFieldsClick = async () => {
|
||||
if (type === 'document') {
|
||||
|
||||
@ -4,9 +4,8 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
@ -68,47 +67,10 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
if (!fileRejections.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||
const { file, errors } = fileRejections[0];
|
||||
|
||||
if (!errors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorNodes = errors.map((error, index) => (
|
||||
<span key={index} className="block">
|
||||
{match(error.code)
|
||||
.with(ErrorCode.FileTooLarge, () => (
|
||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
||||
))
|
||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
||||
.with(ErrorCode.TooManyFiles, () => (
|
||||
<Trans>Only one file can be uploaded at a time</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Unknown error</Trans>
|
||||
))}
|
||||
</span>
|
||||
));
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<span className="font-medium">
|
||||
{file.name} <Trans>couldn't be uploaded:</Trans>
|
||||
</span>
|
||||
{errorNodes}
|
||||
</>
|
||||
);
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title: _(msg`Upload failed`),
|
||||
description,
|
||||
title: _(msg`Your template failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@ -126,8 +88,8 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
||||
void onFileDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
onDropRejected: (fileRejections) => {
|
||||
onFileDropRejected(fileRejections);
|
||||
onDropRejected: () => {
|
||||
void onFileDropRejected();
|
||||
},
|
||||
noClick: true,
|
||||
noDragEventsBubbling: true,
|
||||
|
||||
@ -124,36 +124,32 @@ export const TemplateEditForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
const { signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
return updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
...data.meta,
|
||||
emailReplyTo: data.meta.emailReplyTo || null,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
try {
|
||||
await saveSettingsData(data);
|
||||
await updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
...data.meta,
|
||||
emailReplyTo: data.meta.emailReplyTo || null,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
setStep('signers');
|
||||
} catch (err) {
|
||||
@ -167,44 +163,24 @@ export const TemplateEditForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
try {
|
||||
await saveSettingsData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while auto-saving the template settings.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
|
||||
const [, recipients] = await Promise.all([
|
||||
updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
meta: {
|
||||
signingOrder: data.signingOrder,
|
||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||
},
|
||||
}),
|
||||
|
||||
setRecipients({
|
||||
templateId: template.id,
|
||||
recipients: data.signers,
|
||||
}),
|
||||
]);
|
||||
|
||||
return recipients;
|
||||
};
|
||||
|
||||
const onAddTemplatePlaceholderFormSubmit = async (
|
||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||
) => {
|
||||
try {
|
||||
await saveTemplatePlaceholderData(data);
|
||||
await Promise.all([
|
||||
updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
meta: {
|
||||
signingOrder: data.signingOrder,
|
||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||
},
|
||||
}),
|
||||
|
||||
setRecipients({
|
||||
templateId: template.id,
|
||||
recipients: data.signers,
|
||||
}),
|
||||
]);
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
@ -216,48 +192,12 @@ export const TemplateEditForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onAddTemplatePlaceholderFormAutoSave = async (
|
||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||
) => {
|
||||
try {
|
||||
return await saveTemplatePlaceholderData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while auto-saving the template placeholders.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
|
||||
return addTemplateFields({
|
||||
templateId: template.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormSchema) => {
|
||||
try {
|
||||
await saveFieldsData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while auto-saving the template fields.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
||||
try {
|
||||
await saveFieldsData(data);
|
||||
await addTemplateFields({
|
||||
templateId: template.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
// Clear all field data from localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
@ -330,12 +270,11 @@ export const TemplateEditForm = ({
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
onAutoSave={onAddSettingsFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplatePlaceholderRecipientsFormPartial
|
||||
key={template.id}
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
@ -343,17 +282,15 @@ export const TemplateEditForm = ({
|
||||
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||
templateDirectLink={template.directLink}
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplateFieldsFormPartial
|
||||
key={template.id}
|
||||
key={fields.length}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
onAutoSave={onAddFieldsFormAutoSave}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
</Stepper>
|
||||
|
||||
@ -67,7 +67,7 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
|
||||
{
|
||||
templateId,
|
||||
page: parsedSearchParams.page,
|
||||
|
||||
@ -18,7 +18,7 @@ export const TemplatePageViewRecentActivity = ({
|
||||
templateId,
|
||||
documentRootPath,
|
||||
}: TemplatePageViewRecentActivityProps) => {
|
||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.find.useQuery({
|
||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
|
||||
templateId,
|
||||
orderByColumn: 'createdAt',
|
||||
orderByDirection: 'asc',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -22,11 +22,34 @@ export type VerifyEmailBannerProps = {
|
||||
|
||||
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
|
||||
|
||||
const shouldShowDialog = () => {
|
||||
try {
|
||||
const emailVerificationDialogLastShown = localStorage.getItem(
|
||||
'emailVerificationDialogLastShown',
|
||||
);
|
||||
|
||||
if (emailVerificationDialogLastShown) {
|
||||
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||
|
||||
if (Date.now() - lastShownTimestamp < ONE_DAY) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the timestamp when showing the dialog
|
||||
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
|
||||
return true;
|
||||
} catch {
|
||||
// In case localStorage is not available (SSR, incognito mode, etc.)
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(shouldShowDialog);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||
@ -62,27 +85,6 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||
setIsPending(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage to see if we've recently automatically displayed the dialog
|
||||
// if it was within the past 24 hours, don't show it again
|
||||
// otherwise, show it again and update the localStorage timestamp
|
||||
const emailVerificationDialogLastShown = localStorage.getItem(
|
||||
'emailVerificationDialogLastShown',
|
||||
);
|
||||
|
||||
if (emailVerificationDialogLastShown) {
|
||||
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||
|
||||
if (Date.now() - lastShownTimestamp < ONE_DAY) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
|
||||
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-yellow-200 dark:bg-yellow-400">
|
||||
|
||||
@ -52,7 +52,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateRecipient } = trpc.admin.recipient.update.useMutation();
|
||||
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
|
||||
@ -34,7 +34,7 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.auditLog.find.useQuery(
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentAuditLogs.useQuery(
|
||||
{
|
||||
documentId,
|
||||
page: parsedSearchParams.page,
|
||||
|
||||
@ -45,7 +45,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.get.query(
|
||||
? await trpcClient.document.getDocumentById.query(
|
||||
{
|
||||
documentId: row.id,
|
||||
},
|
||||
|
||||
@ -77,7 +77,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.get.query({
|
||||
? await trpcClient.document.getDocumentById.query({
|
||||
documentId: row.id,
|
||||
})
|
||||
: await trpcClient.document.getDocumentByToken.query({
|
||||
@ -103,7 +103,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
const onDownloadOriginalClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.get.query({
|
||||
? await trpcClient.document.getDocumentById.query({
|
||||
documentId: row.id,
|
||||
})
|
||||
: await trpcClient.document.getDocumentByToken.query({
|
||||
|
||||
@ -11,7 +11,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
|
||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
|
||||
@ -17,6 +17,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
|
||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
@ -31,12 +32,12 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||
|
||||
export type DocumentsTableProps = {
|
||||
data?: TFindInboxResponse;
|
||||
data?: TFindDocumentsResponse;
|
||||
isLoading?: boolean;
|
||||
isLoadingError?: boolean;
|
||||
};
|
||||
|
||||
type DocumentsTableRow = TFindInboxResponse['data'][number];
|
||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
||||
|
||||
export const InboxTable = () => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
@ -62,7 +62,7 @@ export const SettingsSecurityPasskeyTableActions = ({
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } =
|
||||
trpc.auth.passkey.update.useMutation({
|
||||
trpc.auth.updatePasskey.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
@ -84,7 +84,7 @@ export const SettingsSecurityPasskeyTableActions = ({
|
||||
});
|
||||
|
||||
const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } =
|
||||
trpc.auth.passkey.delete.useMutation({
|
||||
trpc.auth.deletePasskey.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
|
||||
@ -26,7 +26,7 @@ export const SettingsSecurityPasskeyTable = () => {
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.auth.passkey.find.useQuery(
|
||||
const { data, isLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
|
||||
{
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
|
||||
@ -48,7 +48,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
|
||||
trpc.admin.document.reseal.useMutation({
|
||||
trpc.admin.resealDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
|
||||
@ -33,7 +33,7 @@ export default function AdminDocumentsPage() {
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
|
||||
const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
|
||||
trpc.admin.document.find.useQuery(
|
||||
trpc.admin.findDocuments.useQuery(
|
||||
{
|
||||
query: debouncedTerm,
|
||||
page: page || 1,
|
||||
|
||||
@ -71,23 +71,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
|
||||
trpc.admin.organisationMember.promoteToOwner.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Member promoted to owner successfully`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`We couldn't promote the member to owner. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const teamsColumns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@ -118,26 +101,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t`Actions`,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={row.original.userId === organisation?.ownerUserId}
|
||||
loading={isPromotingToOwner}
|
||||
onClick={async () =>
|
||||
promoteToOwner({
|
||||
organisationId,
|
||||
userId: row.original.userId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Promote to owner</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
||||
}, [organisation]);
|
||||
|
||||
|
||||
@ -2,14 +2,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
|
||||
import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
@ -33,12 +33,12 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
|
||||
|
||||
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
|
||||
|
||||
const ZUserFormSchema = ZUpdateUserRequestSchema.omit({ id: true });
|
||||
const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
|
||||
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
|
||||
export default function UserPage({ params }: { params: { id: number } }) {
|
||||
const { data: user, isLoading: isLoadingUser } = trpc.admin.user.get.useQuery(
|
||||
const { data: user, isLoading: isLoadingUser } = trpc.profile.getUser.useQuery(
|
||||
{
|
||||
id: Number(params.id),
|
||||
},
|
||||
@ -78,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
return <AdminUserPage user={user} />;
|
||||
}
|
||||
|
||||
const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
||||
const AdminUserPage = ({ user }: { user: User }) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const roles = user.roles ?? [];
|
||||
|
||||
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
|
||||
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
|
||||
|
||||
const form = useForm<TUserFormSchema>({
|
||||
resolver: zodResolver(ZUserFormSchema),
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
GroupIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
Users2Icon,
|
||||
} from 'lucide-react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
@ -78,11 +77,6 @@ export default function SettingsLayout() {
|
||||
label: t`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/sso`,
|
||||
label: t`SSO`,
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/billing`,
|
||||
label: t`Billing`,
|
||||
@ -100,13 +94,6 @@ export default function SettingsLayout() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
|
||||
route.path.includes('/sso')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user