mirror of
https://github.com/documenso/documenso.git
synced 2025-11-26 22:44:41 +10:00
Compare commits
1 Commits
0bbd9aa9a1
...
exp/effect
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4d33d039 |
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!
|
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
|
### Fetch, configure, and build
|
||||||
|
|
||||||
First, clone the code from Github:
|
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.
|
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
|
### 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
|
### 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
|
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`
|
`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
|
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||||
# 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
|
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**)
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||||
|
|
||||||
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
|
||||||
|
|
||||||
## Docker
|
## 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:
|
Each PO file contains translations which look like this:
|
||||||
|
|
||||||
```po
|
```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>"
|
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>"
|
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
|
```bash
|
||||||
npm i
|
npm i
|
||||||
npm run build
|
npm run build:web
|
||||||
npm run prisma:migrate-deploy
|
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.
|
This will start the server on `localhost:3000`. Any reverse proxy can handle the front end and SSL termination.
|
||||||
|
|
||||||
<Callout type="info">
|
<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>
|
</Callout>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
@@ -119,89 +119,16 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
|||||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Set Up Your Signing Certificate
|
### Update the Volume Binding
|
||||||
|
|
||||||
<Callout type="warning">
|
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:
|
||||||
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. You have three options:
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
|
||||||
|
```
|
||||||
|
|
||||||
#### Option A: Generate Certificate Inside Container (Recommended)
|
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env up -d
|
docker-compose --env-file ./.env up -d
|
||||||
@@ -322,7 +249,7 @@ After=network.target
|
|||||||
Environment=PATH=/path/to/your/node/binaries
|
Environment=PATH=/path/to/your/node/binaries
|
||||||
Type=simple
|
Type=simple
|
||||||
User=www-data
|
User=www-data
|
||||||
WorkingDirectory=/var/www/documenso/apps/remix
|
WorkingDirectory=/var/www/documenso/apps/web
|
||||||
ExecStart=/usr/bin/next start -p 3500
|
ExecStart=/usr/bin/next start -p 3500
|
||||||
TimeoutSec=15
|
TimeoutSec=15
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ device, and other FDA-regulated industries.
|
|||||||
- [x] User Access Management
|
- [x] User Access Management
|
||||||
- [x] Quality Assurance Documentation
|
- [x] Quality Assurance Documentation
|
||||||
|
|
||||||
## SOC 2
|
## SOC/ SOC II
|
||||||
|
|
||||||
<Callout type="info" emoji="✅">
|
<Callout type="warning" emoji="⏳">
|
||||||
Status: [Compliant](https://documen.so/trust)
|
Status: [Planned](https://github.com/documenso/backlog/issues/24)
|
||||||
</Callout>
|
</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
|
and data privacy in cloud and IT service organizations, established by the American Institute of Certified
|
||||||
Public Accountants (AICPA).
|
Public Accountants (AICPA).
|
||||||
|
|
||||||
@@ -34,9 +34,9 @@ Public Accountants (AICPA).
|
|||||||
<Callout type="warning" emoji="⏳">
|
<Callout type="warning" emoji="⏳">
|
||||||
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
||||||
</Callout>
|
</Callout>
|
||||||
ISO 27001 is an international standard for managing information security, specifying requirements
|
ISO 27001 is an international standard for managing information security, specifying requirements for
|
||||||
for establishing, implementing, maintaining, and continually improving an information security
|
establishing, implementing, maintaining, and continually improving an information security management
|
||||||
management system (ISMS).
|
system (ISMS).
|
||||||
|
|
||||||
### HIPAA
|
### HIPAA
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,5 @@
|
|||||||
"members": "Members",
|
"members": "Members",
|
||||||
"groups": "Groups",
|
"groups": "Groups",
|
||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
"sso": "SSO",
|
|
||||||
"billing": "Billing"
|
"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 [reason, setReason] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
|
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
|
||||||
trpc.admin.document.delete.useMutation();
|
trpc.admin.deleteDocument.useMutation();
|
||||||
|
|
||||||
const handleDeleteDocument = async () => {
|
const handleDeleteDocument = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
@@ -96,16 +96,17 @@ export const AdminOrganisationCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}, [open, form]);
|
}
|
||||||
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type AdminUserDeleteDialogProps = {
|
export type AdminUserDeleteDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: TGetUserResponse;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
|
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
|
||||||
@@ -35,7 +35,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
||||||
trpc.admin.user.delete.useMutation();
|
trpc.admin.deleteUser.useMutation();
|
||||||
|
|
||||||
const onDeleteAccount = async () => {
|
const onDeleteAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type AdminUserDisableDialogProps = {
|
export type AdminUserDisableDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
userToDisable: TGetUserResponse;
|
userToDisable: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserDisableDialog = ({
|
export const AdminUserDisableDialog = ({
|
||||||
@@ -37,7 +37,7 @@ export const AdminUserDisableDialog = ({
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: disableUser, isPending: isDisablingUser } =
|
const { mutateAsync: disableUser, isPending: isDisablingUser } =
|
||||||
trpc.admin.user.disable.useMutation();
|
trpc.admin.disableUser.useMutation();
|
||||||
|
|
||||||
const onDisableAccount = async () => {
|
const onDisableAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type AdminUserEnableDialogProps = {
|
export type AdminUserEnableDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
userToEnable: TGetUserResponse;
|
userToEnable: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
|
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
|
||||||
@@ -34,7 +34,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: enableUser, isPending: isEnablingUser } =
|
const { mutateAsync: enableUser, isPending: isEnablingUser } =
|
||||||
trpc.admin.user.enable.useMutation();
|
trpc.admin.enableUser.useMutation();
|
||||||
|
|
||||||
const onEnableAccount = async () => {
|
const onEnableAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type AdminUserResetTwoFactorDialogProps = {
|
export type AdminUserResetTwoFactorDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: TGetUserResponse;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserResetTwoFactorDialog = ({
|
export const AdminUserResetTwoFactorDialog = ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -49,7 +49,7 @@ export const DocumentDeleteDialog = ({
|
|||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
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 () => {
|
onSuccess: async () => {
|
||||||
void refreshLimits();
|
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>) => {
|
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputValue(event.target.value);
|
setInputValue(event.target.value);
|
||||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
setInputValue('');
|
||||||
|
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||||
|
}
|
||||||
|
if (!isPending) {
|
||||||
|
onOpenChange(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@@ -36,12 +36,11 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
||||||
{
|
{
|
||||||
documentId: id,
|
documentId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
queryHash: `document-duplicate-dialog-${id}`,
|
|
||||||
enabled: open === true,
|
enabled: open === true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -56,7 +55,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicate.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: async ({ documentId }) => {
|
onSuccess: async ({ documentId }) => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -83,15 +83,6 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
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) => {
|
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await moveDocumentToFolder({
|
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) =>
|
const filteredFolders = folders?.data.filter((folder) =>
|
||||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||||||
document.status !== 'PENDING' ||
|
document.status !== 'PENDING' ||
|
||||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
!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>({
|
const form = useForm<TResendDocumentFormSchema>({
|
||||||
resolver: zodResolver(ZResendDocumentFormSchema),
|
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
@@ -80,14 +80,15 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!isCreateFolderOpen) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [isCreateFolderOpen, form]);
|
setIsCreateFolderOpen(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@@ -92,14 +90,15 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!isOpen) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
onOpenChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
@@ -97,12 +97,13 @@ export const FolderMoveDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!isOpen) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
}
|
}
|
||||||
}, [isOpen, form]);
|
onOpenChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
// Filter out the current folder, only show folders of the same type, and filter by search term
|
// Filter out the current folder, only show folders of the same type, and filter by search term
|
||||||
const filteredFolders = foldersData?.filter(
|
const filteredFolders = foldersData?.filter(
|
||||||
@@ -113,7 +114,7 @@ export const FolderMoveDialog = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } 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) => {
|
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
return;
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
|
|
||||||
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(actionSearchParam === 'add-organisation');
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||||
@@ -91,6 +91,19 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
enabled: IS_BILLING_ENABLED(),
|
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) => {
|
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const response = await createOrganisation({
|
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) => {
|
const isIndividualPlan = (priceId: string) => {
|
||||||
return (
|
return (
|
||||||
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
|
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
|
||||||
@@ -145,11 +147,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
@@ -314,13 +312,16 @@ const BillingPlanForm = ({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [plans]);
|
}, [plans, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value === '' && !canCreateFreeOrganisation) {
|
if (value === '' && !canCreateFreeOrganisation && dynamicPlans.length > 0) {
|
||||||
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
|
const defaultValue = dynamicPlans[0][billingPeriod]?.id ?? '';
|
||||||
|
if (defaultValue) {
|
||||||
|
onChange(defaultValue);
|
||||||
}
|
}
|
||||||
}, [value]);
|
}
|
||||||
|
}, [canCreateFreeOrganisation, dynamicPlans, billingPeriod, onChange, value]);
|
||||||
|
|
||||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
||||||
const plan = dynamicPlans.find(
|
const plan = dynamicPlans.find(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -93,14 +93,19 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (form.formState.isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
setOpen(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
@@ -73,13 +73,6 @@ export const OrganisationEmailCreateDialog = ({
|
|||||||
const { mutateAsync: createOrganisationEmail, isPending } =
|
const { mutateAsync: createOrganisationEmail, isPending } =
|
||||||
trpc.enterprise.organisation.email.create.useMutation();
|
trpc.enterprise.organisation.email.create.useMutation();
|
||||||
|
|
||||||
// Reset state when dialog closes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
|
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await createOrganisationEmail({
|
await createOrganisationEmail({
|
||||||
@@ -114,8 +107,17 @@ export const OrganisationEmailCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
if (!value) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
if (!isPending) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export const OrganisationEmailDeleteDialog = ({
|
|||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
if (!isDeleting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { mutateAsync: deleteEmail, isPending: isDeleting } =
|
const { mutateAsync: deleteEmail, isPending: isDeleting } =
|
||||||
trpc.enterprise.organisation.email.delete.useMutation({
|
trpc.enterprise.organisation.email.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -58,7 +64,7 @@ export const OrganisationEmailDeleteDialog = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
@@ -75,14 +75,6 @@ export const OrganisationEmailDomainCreateDialog = ({
|
|||||||
const { mutateAsync: createOrganisationEmail } =
|
const { mutateAsync: createOrganisationEmail } =
|
||||||
trpc.enterprise.organisation.emailDomain.create.useMutation();
|
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) => {
|
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { records } = await createOrganisationEmail({
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
@@ -87,23 +87,20 @@ export const OrganisationEmailUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (value) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
emailName: organisationEmail.emailName,
|
emailName: organisationEmail.emailName,
|
||||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||||
});
|
});
|
||||||
}, [open, form]);
|
}
|
||||||
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger}
|
{trigger}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
@@ -117,16 +117,17 @@ export const OrganisationGroupCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}, [open, form]);
|
}
|
||||||
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -193,13 +193,6 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
return 'form';
|
return 'form';
|
||||||
}, [fullOrganisation]);
|
}, [fullOrganisation]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
setInvitationType('INDIVIDUAL');
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!e.target.files?.length) {
|
if (!e.target.files?.length) {
|
||||||
return;
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -106,32 +106,27 @@ export const OrganisationMemberUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (value) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
||||||
) {
|
) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}
|
||||||
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -65,9 +65,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createPasskeyRegistrationOptions, isPending } =
|
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) => {
|
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -120,24 +120,21 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
|||||||
return passkeyName;
|
return passkeyName;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleDialogOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (!value) {
|
||||||
const defaultPasskeyName = extractDefaultPasskeyName();
|
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
passkeyName: defaultPasskeyName,
|
passkeyName: defaultPasskeyName,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleDialogOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary" loading={isPending}>
|
<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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -66,14 +66,14 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const organisation = useCurrentOrganisation();
|
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({
|
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
||||||
organisationReference: organisation.id,
|
organisationReference: organisation.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(ZCreateTeamFormSchema),
|
resolver: zodResolver(ZCreateTeamFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -85,6 +85,18 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
|
|
||||||
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
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) => {
|
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await createTeam({
|
await createTeam({
|
||||||
@@ -150,23 +162,8 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
return 'form';
|
return 'form';
|
||||||
}, [fullOrganisation]);
|
}, [fullOrganisation]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionSearchParam === 'add-team') {
|
|
||||||
setOpen(true);
|
|
||||||
updateSearchParams({ action: null });
|
|
||||||
}
|
|
||||||
}, [actionSearchParam, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset();
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -114,14 +114,17 @@ export const TeamDeleteDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -103,18 +103,17 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline" loading={isPending} className="bg-background">
|
<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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -92,18 +92,17 @@ export const TeamEmailUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline" className="bg-background">
|
<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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
@@ -107,7 +107,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
handleClose();
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: t`An unknown error occurred`,
|
title: t`An unknown error occurred`,
|
||||||
@@ -117,17 +117,23 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleClose = () => {
|
||||||
if (!open) {
|
setOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
setStep('SELECT');
|
setStep('SELECT');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
handleClose();
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
{...props}
|
{...props}
|
||||||
open={open}
|
open={open}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
// 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.
|
// 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -106,22 +106,17 @@ export const TeamGroupUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (!form.formState.isSubmitting) {
|
||||||
return;
|
if (value) {
|
||||||
}
|
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setOpen(value);
|
||||||
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
@@ -119,20 +119,17 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
setStep('SELECT');
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
|
||||||
// Since it would be annoying to redo the whole process.
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||||
<Trans>Add members</Trans>
|
<Trans>Add members</Trans>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -106,30 +106,25 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (value) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}
|
||||||
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<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 { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -186,16 +186,20 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
const isLoading =
|
const isLoading =
|
||||||
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
if (!isLoading) {
|
||||||
|
if (value) {
|
||||||
resetCreateTemplateDirectLink();
|
resetCreateTemplateDirectLink();
|
||||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||||
setSelectedRecipientId(null);
|
setSelectedRecipientId(null);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
onOpenChange(value);
|
||||||
}, [open]);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<fieldset disabled={isLoading} className="relative">
|
<fieldset disabled={isLoading} className="relative">
|
||||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||||
{match({ token, currentStep })
|
{match({ token, currentStep })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -85,15 +85,6 @@ export function TemplateMoveToFolderDialog({
|
|||||||
|
|
||||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
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) => {
|
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await moveTemplateToFolder({
|
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) =>
|
const filteredFolders = folders?.data?.filter((folder) =>
|
||||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog {...props} open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -203,14 +203,17 @@ export function TemplateUseDialog({
|
|||||||
name: 'recipients',
|
name: 'recipients',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (form.formState.isSubmitting) return;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
setOpen(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger || (
|
{trigger || (
|
||||||
<Button variant="outline" className="bg-background">
|
<Button variant="outline" className="bg-background">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
|
|
||||||
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
|
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
|
||||||
|
|
||||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({
|
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
onDelete?.();
|
onDelete?.();
|
||||||
},
|
},
|
||||||
@@ -95,17 +95,17 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!isOpen) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [isOpen, form]);
|
if (!form.formState.isSubmitting) {
|
||||||
|
setIsOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild={true}>
|
<DialogTrigger asChild={true}>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<Button className="mr-4" variant="destructive">
|
<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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -88,14 +88,17 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (!open) {
|
if (!value) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
if (!form.formState.isSubmitting) {
|
||||||
|
setOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<Button className="mr-4" variant="destructive">
|
<Button className="mr-4" variant="destructive">
|
||||||
|
|||||||
@@ -172,8 +172,6 @@ export const ConfigureFieldsView = ({
|
|||||||
name: 'fields',
|
name: 'fields',
|
||||||
});
|
});
|
||||||
|
|
||||||
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
@@ -542,9 +540,7 @@ export const ConfigureFieldsView = ({
|
|||||||
<div>
|
<div>
|
||||||
<PDFViewer documentData={normalizedDocumentData} />
|
<PDFViewer documentData={normalizedDocumentData} />
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{localFields.map((field, index) => {
|
{localFields.map((field, index) => {
|
||||||
const recipientIndex = recipients.findIndex(
|
const recipientIndex = recipients.findIndex(
|
||||||
(r) => r.id === field.recipientId,
|
(r) => r.id === field.recipientId,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
token,
|
token,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
documentData,
|
documentData,
|
||||||
recipient: _recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
@@ -91,12 +91,8 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
|
||||||
|
|
||||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
||||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||||
|
|
||||||
@@ -347,34 +343,19 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<Trans>Sign document</Trans>
|
<Trans>Sign document</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<Button
|
<LucideChevronDown
|
||||||
variant="outline"
|
className="text-muted-foreground h-5 w-5"
|
||||||
className="h-8 w-8 p-0 md:hidden"
|
|
||||||
onClick={() => setIsExpanded(false)}
|
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
|
<LucideChevronUp
|
||||||
variant="default"
|
className="text-muted-foreground h-5 w-5"
|
||||||
size="sm"
|
onClick={() => setIsExpanded(true)}
|
||||||
className="md:hidden"
|
/>
|
||||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={() => throttledOnCompleteClick()}
|
|
||||||
>
|
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -461,9 +442,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<Trans>Click to insert field</Trans>
|
||||||
|
|||||||
@@ -50,10 +50,8 @@ export const EmbedDocumentFields = ({
|
|||||||
onSignField,
|
onSignField,
|
||||||
onUnsignField,
|
onUnsignField,
|
||||||
}: EmbedDocumentFieldsProps) => {
|
}: EmbedDocumentFieldsProps) => {
|
||||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
{fields.map((field) =>
|
{fields.map((field) =>
|
||||||
match(field.type)
|
match(field.type)
|
||||||
.with(FieldType.SIGNATURE, () => (
|
.with(FieldType.SIGNATURE, () => (
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
@@ -106,8 +106,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
fields.filter((field) => field.inserted),
|
fields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
@@ -118,8 +116,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
@@ -309,36 +305,19 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<Button
|
<LucideChevronDown
|
||||||
variant="outline"
|
className="text-muted-foreground h-5 w-5"
|
||||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
|
||||||
onClick={() => setIsExpanded(false)}
|
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
|
<LucideChevronUp
|
||||||
variant="default"
|
className="text-muted-foreground h-5 w-5"
|
||||||
size="sm"
|
onClick={() => setIsExpanded(true)}
|
||||||
className="md:hidden"
|
/>
|
||||||
disabled={
|
|
||||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
|
||||||
}
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={() => throttledOnCompleteClick()}
|
|
||||||
>
|
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -486,9 +465,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<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';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
export const EmbedDocumentWaitingForTurn = () => {
|
export const EmbedDocumentWaitingForTurn = () => {
|
||||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.parent && !hasPostedMessage) {
|
if (window.parent) {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
action: 'document-waiting-for-turn',
|
action: 'document-waiting-for-turn',
|
||||||
@@ -15,13 +13,7 @@ export const EmbedDocumentWaitingForTurn = () => {
|
|||||||
'*',
|
'*',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
setHasPostedMessage(true);
|
|
||||||
}, [hasPostedMessage]);
|
|
||||||
|
|
||||||
if (!hasPostedMessage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<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 uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||||
|
|
||||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||||
@@ -359,9 +357,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasDocumentLoaded && (
|
{hasDocumentLoaded && (
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip
|
<FieldToolTip
|
||||||
key={pendingFields[0].id}
|
key={pendingFields[0].id}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -136,18 +136,19 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
enable2FAForm.reset();
|
setIsOpen(open);
|
||||||
|
|
||||||
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
if (!open) {
|
||||||
|
enable2FAForm.reset();
|
||||||
|
if (recoveryCodes && recoveryCodes.length > 0) {
|
||||||
setRecoveryCodes(null);
|
setRecoveryCodes(null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
};
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild={true}>
|
<DialogTrigger asChild={true}>
|
||||||
<Button
|
<Button
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
@@ -114,11 +114,20 @@ export const SignInForm = ({
|
|||||||
}, [returnTo]);
|
}, [returnTo]);
|
||||||
|
|
||||||
const { mutateAsync: createPasskeySigninOptions } =
|
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>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: initialEmail ?? '',
|
email: emailFromHash ?? initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
totpCode: '',
|
totpCode: '',
|
||||||
backupCode: '',
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
@@ -84,10 +84,19 @@ export const SignUpForm = ({
|
|||||||
|
|
||||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
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>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: '',
|
name: '',
|
||||||
email: initialEmail ?? '',
|
email: emailFromHash ?? initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
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 (
|
return (
|
||||||
<div className={cn('flex justify-center gap-x-12', className)}>
|
<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">
|
<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 { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@@ -47,7 +47,7 @@ export const EXPIRATION_DATES = {
|
|||||||
ONE_YEAR: msg`12 months`,
|
ONE_YEAR: msg`12 months`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
|
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.pick({
|
||||||
tokenName: true,
|
tokenName: true,
|
||||||
expirationDate: true,
|
expirationDate: true,
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
|||||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
|
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
|
||||||
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
|
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
setNewlyCreatedToken(data);
|
setNewlyCreatedToken(data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { TooltipProps } from 'recharts';
|
import type { TooltipProps } from 'recharts';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } 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 [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||||
trpcReact.document.search.useQuery(
|
trpcReact.document.searchDocuments.useQuery(
|
||||||
{
|
{
|
||||||
query: search,
|
query: search,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ export const DirectTemplateSigningForm = ({
|
|||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
|
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(() => {
|
const fieldsRequiringValidation = useMemo(() => {
|
||||||
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||||
}, [localFields]);
|
}, [localFields]);
|
||||||
@@ -223,9 +221,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
|
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{validateUninsertedFields && uninsertedFields[0] && (
|
{validateUninsertedFields && uninsertedFields[0] && (
|
||||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<Trans>Click to insert field</Trans>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const DocumentSigningAuthPasskey = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createPasskeyAuthenticationOptions } =
|
const { mutateAsync: createPasskeyAuthenticationOptions } =
|
||||||
trpc.auth.passkey.createAuthenticationOptions.useMutation();
|
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
|
||||||
|
|
||||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
[documentAuthOptions, recipient],
|
[documentAuthOptions, recipient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const passkeyQuery = trpc.auth.passkey.find.useQuery(
|
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
|
||||||
{
|
{
|
||||||
perPage: MAXIMUM_PASSKEYS,
|
perPage: MAXIMUM_PASSKEYS,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
import { P, match } from 'ts-pattern';
|
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 { AUTO_SIGNABLE_FIELD_TYPES } from '@documenso/lib/constants/autosign';
|
||||||
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
@@ -61,12 +60,6 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
|||||||
const { email, fullName } = useRequiredDocumentSigningContext();
|
const { email, fullName } = useRequiredDocumentSigningContext();
|
||||||
const { derivedRecipientActionAuth } = useRequiredDocumentSigningAuthContext();
|
const { derivedRecipientActionAuth } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm();
|
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
|
||||||
|
|
||||||
const autoSignableFields = fields.filter((field) => {
|
const autoSignableFields = fields.filter((field) => {
|
||||||
if (field.inserted) {
|
if (field.inserted) {
|
||||||
return false;
|
return false;
|
||||||
@@ -95,6 +88,14 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
|||||||
(actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
|
(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 onSubmit = async () => {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
autoSignableFields.map(async (field) => {
|
autoSignableFields.map(async (field) => {
|
||||||
@@ -152,12 +153,6 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
|||||||
await revalidate();
|
await revalidate();
|
||||||
};
|
};
|
||||||
|
|
||||||
unsafe_useEffectOnce(() => {
|
|
||||||
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
|
|||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
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 { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientActionAuth } 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 { 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 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 { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@@ -31,33 +34,29 @@ export type DocumentSigningFormProps = {
|
|||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
redirectUrl?: string | null;
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
setSelectedSignerId?: (id: number | null) => void;
|
||||||
completeDocument: (
|
|
||||||
authOptions?: TRecipientActionAuth,
|
|
||||||
nextSigner?: { email: string; name: string },
|
|
||||||
) => Promise<void>;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
fieldsValidated: () => void;
|
|
||||||
nextRecipient?: RecipientWithFields;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningForm = ({
|
export const DocumentSigningForm = ({
|
||||||
document,
|
document,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
|
redirectUrl,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
setSelectedSignerId,
|
setSelectedSignerId,
|
||||||
completeDocument,
|
|
||||||
isSubmitting,
|
|
||||||
fieldsValidated,
|
|
||||||
nextRecipient,
|
|
||||||
}: DocumentSigningFormProps) => {
|
}: DocumentSigningFormProps) => {
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
const user = sessionData?.user;
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
@@ -67,12 +66,21 @@ export const DocumentSigningForm = ({
|
|||||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: completeDocumentWithToken,
|
||||||
|
isPending,
|
||||||
|
isSuccess,
|
||||||
|
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
selectedSignerId: undefined,
|
selectedSignerId: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
|
const isSubmitting = isPending || isSuccess;
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
const fieldsRequiringValidation = useMemo(
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
() => fields.filter(isFieldUnsignedAndRequired),
|
||||||
[fields],
|
[fields],
|
||||||
@@ -88,9 +96,9 @@ export const DocumentSigningForm = ({
|
|||||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||||
}, [fieldsRequiringValidation, recipient]);
|
}, [fieldsRequiringValidation, recipient]);
|
||||||
|
|
||||||
const localFieldsValidated = () => {
|
const fieldsValidated = () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
fieldsValidated();
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{validateUninsertedFields && uninsertedFields[0] && (
|
{validateUninsertedFields && uninsertedFields[0] && (
|
||||||
@@ -148,7 +205,7 @@ export const DocumentSigningForm = ({
|
|||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={localFieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) => {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument(undefined, nextSigner);
|
||||||
}}
|
}}
|
||||||
@@ -307,7 +364,7 @@ export const DocumentSigningForm = ({
|
|||||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={localFieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) => {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument(undefined, nextSigner);
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field } from '@prisma/client';
|
import type { Field } from '@prisma/client';
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
import { FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { match } from 'ts-pattern';
|
||||||
import { P, 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 { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
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 { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import {
|
import {
|
||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
ZDropdownFieldMeta,
|
ZDropdownFieldMeta,
|
||||||
@@ -21,11 +18,8 @@ import {
|
|||||||
ZTextFieldMeta,
|
ZTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
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 { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@@ -46,7 +40,6 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
|||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||||
|
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningPageViewProps = {
|
export type DocumentSigningPageViewProps = {
|
||||||
@@ -70,56 +63,9 @@ export const DocumentSigningPageView = ({
|
|||||||
}: DocumentSigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
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 (
|
|
||||||
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 (documentMeta?.redirectUrl) {
|
|
||||||
window.location.href = documentMeta.redirectUrl;
|
|
||||||
} else {
|
|
||||||
await navigate(`/sign/${recipient.token}/complete`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let senderName = document.user.name ?? '';
|
let senderName = document.user.name ?? '';
|
||||||
let senderEmail = `(${document.user.email})`;
|
let senderEmail = `(${document.user.email})`;
|
||||||
|
|
||||||
@@ -132,31 +78,6 @@ export const DocumentSigningPageView = ({
|
|||||||
const targetSigner =
|
const targetSigner =
|
||||||
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
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 (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||||
@@ -242,55 +163,19 @@ export const DocumentSigningPageView = ({
|
|||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{match({ hasPendingFields, isExpanded, role: recipient.role })
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||||
.with(
|
{isExpanded ? (
|
||||||
{
|
<LucideChevronDown
|
||||||
hasPendingFields: false,
|
className="text-muted-foreground h-5 w-5"
|
||||||
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) => {
|
|
||||||
await completeDocument(undefined, nextSigner);
|
|
||||||
}}
|
|
||||||
role={recipient.role}
|
|
||||||
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"
|
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
>
|
/>
|
||||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
) : (
|
||||||
</Button>
|
<LucideChevronUp
|
||||||
))
|
className="text-muted-foreground h-5 w-5"
|
||||||
.otherwise(() => (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
onClick={() => setIsExpanded(true)}
|
||||||
>
|
/>
|
||||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||||
@@ -319,13 +204,10 @@ export const DocumentSigningPageView = ({
|
|||||||
document={document}
|
document={document}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
setSelectedSignerId={setSelectedSignerId}
|
setSelectedSignerId={setSelectedSignerId}
|
||||||
completeDocument={completeDocument}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
nextRecipient={nextRecipient}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,9 +224,7 @@ export const DocumentSigningPageView = ({
|
|||||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{fields
|
{fields
|
||||||
.filter(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -80,20 +80,16 @@ export const DocumentSigningTextField = ({
|
|||||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
||||||
const shouldAutoSignField =
|
const shouldAutoSignField = useMemo(
|
||||||
|
() =>
|
||||||
(!field.inserted && parsedFieldMeta?.text) ||
|
(!field.inserted && parsedFieldMeta?.text) ||
|
||||||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
|
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly),
|
||||||
|
[field.inserted, parsedFieldMeta?.text, parsedFieldMeta?.readOnly],
|
||||||
|
);
|
||||||
|
|
||||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||||
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
|
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showCustomTextModal) {
|
|
||||||
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
|
||||||
setErrors(initialErrors);
|
|
||||||
}
|
|
||||||
}, [showCustomTextModal]);
|
|
||||||
|
|
||||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const text = e.target.value;
|
const text = e.target.value;
|
||||||
setLocalCustomText(text);
|
setLocalCustomText(text);
|
||||||
@@ -216,14 +212,12 @@ export const DocumentSigningTextField = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldAutoSignField) {
|
if (shouldAutoSignField) {
|
||||||
void executeActionAuthProcedure({
|
void executeActionAuthProcedure({
|
||||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||||
actionTarget: field.type,
|
actionTarget: field.type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
||||||
|
|
||||||
@@ -318,7 +312,8 @@ export const DocumentSigningTextField = ({
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCustomTextModal(false);
|
setShowCustomTextModal(false);
|
||||||
setLocalCustomText('');
|
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||||
|
setErrors(initialErrors);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const DocumentAuditLogDownloadButton = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { mutateAsync: downloadAuditLogs, isPending } =
|
const { mutateAsync: downloadAuditLogs, isPending } =
|
||||||
trpc.document.auditLog.download.useMutation();
|
trpc.document.downloadAuditLogs.useMutation();
|
||||||
|
|
||||||
const onDownloadAuditLogsClick = async () => {
|
const onDownloadAuditLogsClick = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
|
|
||||||
const { quota, remaining, refreshLimits } = useLimits();
|
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;
|
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const { data: document, refetch: refetchDocument } = trpc.document.get.useQuery(
|
const { data: document, refetch: refetchDocument } =
|
||||||
|
trpc.document.getDocumentWithDetailsById.useQuery(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
documentId: initialDocument.id,
|
||||||
},
|
},
|
||||||
@@ -71,10 +72,10 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const { recipients, fields } = document;
|
const { recipients, fields } = document;
|
||||||
|
|
||||||
const { mutateAsync: updateDocument } = trpc.document.update.useMutation({
|
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.get.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
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({
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: ({ fields: newFields }) => {
|
onSuccess: ({ fields: newFields }) => {
|
||||||
utils.document.get.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
documentId: initialDocument.id,
|
||||||
},
|
},
|
||||||
@@ -98,7 +112,7 @@ export const DocumentEditForm = ({
|
|||||||
const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({
|
const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: ({ recipients: newRecipients }) => {
|
onSuccess: ({ recipients: newRecipients }) => {
|
||||||
utils.document.get.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
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,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.get.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
documentId: initialDocument.id,
|
||||||
},
|
},
|
||||||
@@ -159,14 +173,15 @@ export const DocumentEditForm = ({
|
|||||||
return initialStep;
|
return initialStep;
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveSettingsData = async (data: TAddSettingsFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
|
try {
|
||||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||||
|
|
||||||
const parsedGlobalAccessAuth = z
|
const parsedGlobalAccessAuth = z
|
||||||
.array(ZDocumentAccessAuthTypesSchema)
|
.array(ZDocumentAccessAuthTypesSchema)
|
||||||
.safeParse(data.globalAccessAuth);
|
.safeParse(data.globalAccessAuth);
|
||||||
|
|
||||||
return updateDocument({
|
await updateDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@@ -185,11 +200,7 @@ export const DocumentEditForm = ({
|
|||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
|
||||||
try {
|
|
||||||
await saveSettingsData(data);
|
|
||||||
setStep('signers');
|
setStep('signers');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -202,27 +213,18 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await saveSettingsData(data);
|
await Promise.all([
|
||||||
} catch (err) {
|
setSigningOrderForDocument({
|
||||||
console.error(err);
|
documentId: document.id,
|
||||||
|
signingOrder: data.signingOrder,
|
||||||
|
}),
|
||||||
|
|
||||||
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({
|
updateDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
meta: {
|
meta: {
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
signingOrder: data.signingOrder,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -235,25 +237,6 @@ export const DocumentEditForm = ({
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
};
|
|
||||||
|
|
||||||
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
|
|
||||||
try {
|
|
||||||
await saveSignersData(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(msg`An error occurred while adding signers.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
|
||||||
try {
|
|
||||||
await saveSignersData(data);
|
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -267,16 +250,12 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||||
return addFields({
|
try {
|
||||||
|
await addFields({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
fields: data.fields,
|
fields: data.fields,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
|
||||||
try {
|
|
||||||
await saveFieldsData(data);
|
|
||||||
|
|
||||||
// Clear all field data from localStorage
|
// Clear all field data from localStorage
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
@@ -298,43 +277,12 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddFieldsFormAutoSave = async (data: TAddFieldsFormSchema) => {
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
|
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||||
|
data.meta;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveFieldsData(data);
|
await sendDocument({
|
||||||
// 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,
|
documentId: document.id,
|
||||||
meta: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
@@ -342,16 +290,11 @@ export const DocumentEditForm = ({
|
|||||||
distributionMethod,
|
distributionMethod,
|
||||||
emailId,
|
emailId,
|
||||||
emailReplyTo: emailReplyTo || null,
|
emailReplyTo: emailReplyTo || null,
|
||||||
emailSettings,
|
emailSettings: emailSettings,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
|
||||||
try {
|
|
||||||
await sendDocumentWithSubject(data);
|
|
||||||
|
|
||||||
if (data.meta.distributionMethod === DocumentDistributionMethod.EMAIL) {
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document sent`),
|
title: _(msg`Document sent`),
|
||||||
description: _(msg`Your document has been sent successfully.`),
|
description: _(msg`Your document has been sent successfully.`),
|
||||||
@@ -379,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];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -439,28 +367,25 @@ export const DocumentEditForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
onAutoSave={onAddSettingsFormAutoSave}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={document.id}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
onAutoSave={onAddSignersFormAutoSave}
|
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
key={document.id}
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
onAutoSave={onAddFieldsFormAutoSave}
|
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
teamId={team.id}
|
teamId={team.id}
|
||||||
/>
|
/>
|
||||||
@@ -472,7 +397,6 @@ export const DocumentEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSubjectFormSubmit}
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
onAutoSave={onAddSubjectFormAutoSave}
|
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||||
{
|
{
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||||
{
|
{
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
},
|
},
|
||||||
@@ -100,7 +100,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
const onDownloadOriginalClick = async () => {
|
const onDownloadOriginalClick = async () => {
|
||||||
try {
|
try {
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||||
{
|
{
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = trpc.document.auditLog.find.useInfiniteQuery(
|
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
||||||
{
|
{
|
||||||
documentId,
|
documentId,
|
||||||
filterForRecentActivity: true,
|
filterForRecentActivity: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -26,16 +26,13 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
|
|||||||
|
|
||||||
setSearchParams(params);
|
setSearchParams(params);
|
||||||
},
|
},
|
||||||
[searchParams],
|
[searchParams, setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const currentQuery = searchParams?.get('query') ?? '';
|
||||||
const currentQueryParam = searchParams.get('query') || '';
|
if (currentQuery !== debouncedSearchTerm) {
|
||||||
|
|
||||||
if (debouncedSearchTerm !== currentQueryParam) {
|
|
||||||
handleSearch(debouncedSearchTerm);
|
handleSearch(debouncedSearchTerm);
|
||||||
}
|
}
|
||||||
}, [debouncedSearchTerm, searchParams]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||||
|
|
||||||
const disabledMessage = useMemo(() => {
|
const disabledMessage = useMemo(() => {
|
||||||
if (organisation.subscription && remaining.documents === 0) {
|
if (organisation.subscription && remaining.documents === 0) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const LegacyFieldWarningPopover = ({
|
|||||||
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
|
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
|
||||||
trpc.template.updateTemplate.useMutation();
|
trpc.template.updateTemplate.useMutation();
|
||||||
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
|
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
|
||||||
trpc.document.update.useMutation();
|
trpc.document.updateDocument.useMutation();
|
||||||
|
|
||||||
const onUpdateFieldsClick = async () => {
|
const onUpdateFieldsClick = async () => {
|
||||||
if (type === 'document') {
|
if (type === 'document') {
|
||||||
|
|||||||
@@ -124,14 +124,15 @@ export const TemplateEditForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||||
const { signatureTypes } = data.meta;
|
const { signatureTypes } = data.meta;
|
||||||
|
|
||||||
const parsedGlobalAccessAuth = z
|
const parsedGlobalAccessAuth = z
|
||||||
.array(ZDocumentAccessAuthTypesSchema)
|
.array(ZDocumentAccessAuthTypesSchema)
|
||||||
.safeParse(data.globalAccessAuth);
|
.safeParse(data.globalAccessAuth);
|
||||||
|
|
||||||
return updateTemplateSettings({
|
try {
|
||||||
|
await updateTemplateSettings({
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@@ -149,11 +150,6 @@ export const TemplateEditForm = ({
|
|||||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
|
||||||
try {
|
|
||||||
await saveSettingsData(data);
|
|
||||||
|
|
||||||
setStep('signers');
|
setStep('signers');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -167,22 +163,11 @@ export const TemplateEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
|
const onAddTemplatePlaceholderFormSubmit = async (
|
||||||
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
await saveSettingsData(data);
|
await Promise.all([
|
||||||
} 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) => {
|
|
||||||
return Promise.all([
|
|
||||||
updateTemplateSettings({
|
updateTemplateSettings({
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -196,13 +181,6 @@ export const TemplateEditForm = ({
|
|||||||
recipients: data.signers,
|
recipients: data.signers,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
};
|
|
||||||
|
|
||||||
const onAddTemplatePlaceholderFormSubmit = async (
|
|
||||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await saveTemplatePlaceholderData(data);
|
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -214,46 +192,12 @@ export const TemplateEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddTemplatePlaceholderFormAutoSave = async (
|
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
||||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
await saveTemplatePlaceholderData(data);
|
await addTemplateFields({
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(msg`An error occurred while auto-saving the template placeholders.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
|
|
||||||
return addTemplateFields({
|
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
fields: data.fields,
|
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);
|
|
||||||
|
|
||||||
// Clear all field data from localStorage
|
// Clear all field data from localStorage
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
@@ -326,12 +270,11 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
onAutoSave={onAddSettingsFormAutoSave}
|
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTemplatePlaceholderRecipientsFormPartial
|
<AddTemplatePlaceholderRecipientsFormPartial
|
||||||
key={template.id}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
@@ -339,17 +282,15 @@ export const TemplateEditForm = ({
|
|||||||
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||||
templateDirectLink={template.directLink}
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
|
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTemplateFieldsFormPartial
|
<AddTemplateFieldsFormPartial
|
||||||
key={template.id}
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
onAutoSave={onAddFieldsFormAutoSave}
|
|
||||||
teamId={team?.id}
|
teamId={team?.id}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const TemplatePageViewDocumentsTable = ({
|
|||||||
Object.fromEntries(searchParams ?? []),
|
Object.fromEntries(searchParams ?? []),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
|
||||||
{
|
{
|
||||||
templateId,
|
templateId,
|
||||||
page: parsedSearchParams.page,
|
page: parsedSearchParams.page,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const TemplatePageViewRecentActivity = ({
|
|||||||
templateId,
|
templateId,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: TemplatePageViewRecentActivityProps) => {
|
}: TemplatePageViewRecentActivityProps) => {
|
||||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.find.useQuery({
|
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
|
||||||
templateId,
|
templateId,
|
||||||
orderByColumn: 'createdAt',
|
orderByColumn: 'createdAt',
|
||||||
orderByDirection: 'asc',
|
orderByDirection: 'asc',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -22,11 +22,34 @@ export type VerifyEmailBannerProps = {
|
|||||||
|
|
||||||
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
|
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) => {
|
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(shouldShowDialog);
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
|
||||||
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||||
@@ -62,27 +85,6 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
|||||||
setIsPending(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="bg-yellow-200 dark:bg-yellow-400">
|
<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(() => {
|
const columns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
|||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
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,
|
documentId,
|
||||||
page: parsedSearchParams.page,
|
page: parsedSearchParams.page,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
const document = !recipient
|
const document = !recipient
|
||||||
? await trpcClient.document.get.query(
|
? await trpcClient.document.getDocumentById.query(
|
||||||
{
|
{
|
||||||
documentId: row.id,
|
documentId: row.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
const document = !recipient
|
const document = !recipient
|
||||||
? await trpcClient.document.get.query({
|
? await trpcClient.document.getDocumentById.query({
|
||||||
documentId: row.id,
|
documentId: row.id,
|
||||||
})
|
})
|
||||||
: await trpcClient.document.getDocumentByToken.query({
|
: await trpcClient.document.getDocumentByToken.query({
|
||||||
@@ -103,7 +103,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
const onDownloadOriginalClick = async () => {
|
const onDownloadOriginalClick = async () => {
|
||||||
try {
|
try {
|
||||||
const document = !recipient
|
const document = !recipient
|
||||||
? await trpcClient.document.get.query({
|
? await trpcClient.document.getDocumentById.query({
|
||||||
documentId: row.id,
|
documentId: row.id,
|
||||||
})
|
})
|
||||||
: await trpcClient.document.getDocumentByToken.query({
|
: 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 { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
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 type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
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 as trpcClient } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } 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';
|
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||||
|
|
||||||
export type DocumentsTableProps = {
|
export type DocumentsTableProps = {
|
||||||
data?: TFindInboxResponse;
|
data?: TFindDocumentsResponse;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isLoadingError?: boolean;
|
isLoadingError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DocumentsTableRow = TFindInboxResponse['data'][number];
|
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
||||||
|
|
||||||
export const InboxTable = () => {
|
export const InboxTable = () => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const SettingsSecurityPasskeyTableActions = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } =
|
const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } =
|
||||||
trpc.auth.passkey.update.useMutation({
|
trpc.auth.updatePasskey.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@@ -84,7 +84,7 @@ export const SettingsSecurityPasskeyTableActions = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } =
|
const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } =
|
||||||
trpc.auth.passkey.delete.useMutation({
|
trpc.auth.deletePasskey.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const SettingsSecurityPasskeyTable = () => {
|
|||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
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,
|
page: parsedSearchParams.page,
|
||||||
perPage: parsedSearchParams.perPage,
|
perPage: parsedSearchParams.perPage,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
|
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
|
||||||
trpc.admin.document.reseal.useMutation({
|
trpc.admin.resealDocument.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function AdminDocumentsPage() {
|
|||||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||||
|
|
||||||
const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
|
const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
|
||||||
trpc.admin.document.find.useQuery(
|
trpc.admin.findDocuments.useQuery(
|
||||||
{
|
{
|
||||||
query: debouncedTerm,
|
query: debouncedTerm,
|
||||||
page: page || 1,
|
page: page || 1,
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||||
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -33,12 +33,12 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
|
|||||||
|
|
||||||
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
|
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>;
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
|
|
||||||
export default function UserPage({ params }: { params: { id: number } }) {
|
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),
|
id: Number(params.id),
|
||||||
},
|
},
|
||||||
@@ -78,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
return <AdminUserPage user={user} />;
|
return <AdminUserPage user={user} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
const AdminUserPage = ({ user }: { user: User }) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const roles = user.roles ?? [];
|
const roles = user.roles ?? [];
|
||||||
|
|
||||||
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
|
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
|
||||||
|
|
||||||
const form = useForm<TUserFormSchema>({
|
const form = useForm<TUserFormSchema>({
|
||||||
resolver: zodResolver(ZUserFormSchema),
|
resolver: zodResolver(ZUserFormSchema),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
GroupIcon,
|
GroupIcon,
|
||||||
MailboxIcon,
|
MailboxIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
ShieldCheckIcon,
|
|
||||||
Users2Icon,
|
Users2Icon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { FaUsers } from 'react-icons/fa6';
|
import { FaUsers } from 'react-icons/fa6';
|
||||||
@@ -78,11 +77,6 @@ export default function SettingsLayout() {
|
|||||||
label: t`Groups`,
|
label: t`Groups`,
|
||||||
icon: GroupIcon,
|
icon: GroupIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/sso`,
|
|
||||||
label: t`SSO`,
|
|
||||||
icon: ShieldCheckIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: `/o/${organisation.url}/settings/billing`,
|
path: `/o/${organisation.url}/settings/billing`,
|
||||||
label: t`Billing`,
|
label: t`Billing`,
|
||||||
@@ -100,13 +94,6 @@ export default function SettingsLayout() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
|
|
||||||
route.path.includes('/sso')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -29,22 +29,29 @@ export default function TeamsSettingsMembersPage() {
|
|||||||
/**
|
/**
|
||||||
* Handle debouncing the search query.
|
* Handle debouncing the search query.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
const handleSearchQueryChange = useCallback(
|
||||||
|
(newQuery: string) => {
|
||||||
const params = new URLSearchParams(searchParams?.toString());
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
params.set('query', debouncedSearchQuery);
|
if (newQuery.trim()) {
|
||||||
|
params.set('query', newQuery);
|
||||||
if (debouncedSearchQuery === '') {
|
} else {
|
||||||
params.delete('query');
|
params.delete('query');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nothing to change then do nothing.
|
|
||||||
if (params.toString() === searchParams?.toString()) {
|
if (params.toString() === searchParams?.toString()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchParams(params);
|
setSearchParams(params);
|
||||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentParamQuery = searchParams?.get('query') ?? '';
|
||||||
|
if (currentParamQuery !== debouncedSearchQuery) {
|
||||||
|
handleSearchQueryChange(debouncedSearchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,432 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
|
||||||
import {
|
|
||||||
formatOrganisationCallbackUrl,
|
|
||||||
formatOrganisationLoginUrl,
|
|
||||||
} from '@documenso/lib/utils/organisation-authentication-portal';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { domainRegex } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types';
|
|
||||||
import type { TGetOrganisationAuthenticationPortalResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-authentication-portal.types';
|
|
||||||
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-authentication-portal.types';
|
|
||||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
|
||||||
|
|
||||||
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
|
|
||||||
.pick({
|
|
||||||
enabled: true,
|
|
||||||
wellKnownUrl: true,
|
|
||||||
clientId: true,
|
|
||||||
autoProvisionUsers: true,
|
|
||||||
defaultOrganisationRole: true,
|
|
||||||
})
|
|
||||||
.extend({
|
|
||||||
clientSecret: z.string().nullable(),
|
|
||||||
allowedDomains: z.string().refine(
|
|
||||||
(value) => {
|
|
||||||
const domains = value.split(' ').filter(Boolean);
|
|
||||||
|
|
||||||
return domains.every((domain) => domainRegex.test(domain));
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: msg`Invalid domains`.id,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TProviderFormSchema = z.infer<typeof ZProviderFormSchema>;
|
|
||||||
|
|
||||||
export function meta() {
|
|
||||||
return appMetaTags('Organisation SSO Portal');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OrganisationSettingSSOLoginPage() {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
|
|
||||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
|
||||||
return <SpinnerBox className="py-32" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<SettingsHeader
|
|
||||||
title={t`Organisation SSO Portal`}
|
|
||||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SSOProviderForm authenticationPortal={authenticationPortal} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SSOProviderFormProps = {
|
|
||||||
authenticationPortal: TGetOrganisationAuthenticationPortalResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const { mutateAsync: updateOrganisationAuthenticationPortal } =
|
|
||||||
trpc.enterprise.organisation.authenticationPortal.update.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TProviderFormSchema>({
|
|
||||||
resolver: zodResolver(ZProviderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
enabled: authenticationPortal.enabled,
|
|
||||||
clientId: authenticationPortal.clientId,
|
|
||||||
clientSecret: authenticationPortal.clientSecretProvided ? null : '',
|
|
||||||
wellKnownUrl: authenticationPortal.wellKnownUrl,
|
|
||||||
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
|
|
||||||
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
|
|
||||||
allowedDomains: authenticationPortal.allowedDomains.join(' '),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (values: TProviderFormSchema) => {
|
|
||||||
const { enabled, clientId, clientSecret, wellKnownUrl } = values;
|
|
||||||
|
|
||||||
if (enabled && !clientId) {
|
|
||||||
form.setError('clientId', {
|
|
||||||
message: t`Client ID is required`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enabled && clientSecret === '') {
|
|
||||||
form.setError('clientSecret', {
|
|
||||||
message: t`Client secret is required`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enabled && !wellKnownUrl) {
|
|
||||||
form.setError('wellKnownUrl', {
|
|
||||||
message: t`Well-known URL is required`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateOrganisationAuthenticationPortal({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
data: {
|
|
||||||
enabled,
|
|
||||||
clientId,
|
|
||||||
clientSecret: values.clientSecret ?? undefined,
|
|
||||||
wellKnownUrl,
|
|
||||||
autoProvisionUsers: values.autoProvisionUsers,
|
|
||||||
defaultOrganisationRole: values.defaultOrganisationRole,
|
|
||||||
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Provider has been updated successfully`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`An error occurred`,
|
|
||||||
description: t`We couldn't update the provider. Please try again.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSsoEnabled = form.watch('enabled');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Organisation authentication portal URL</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
className="pr-12"
|
|
||||||
disabled
|
|
||||||
value={formatOrganisationLoginUrl(organisation.url)}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
|
||||||
<CopyTextButton
|
|
||||||
value={formatOrganisationLoginUrl(organisation.url)}
|
|
||||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
<Trans>This is the URL which users will use to sign in to your organisation.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Redirect URI</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
className="pr-12"
|
|
||||||
disabled
|
|
||||||
value={formatOrganisationCallbackUrl(organisation.url)}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
|
||||||
<CopyTextButton
|
|
||||||
value={formatOrganisationCallbackUrl(organisation.url)}
|
|
||||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
<Trans>Add this URL to your provider's allowed redirect URIs</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Required scopes</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input className="pr-12" disabled value={`openid profile email`} />
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
<Trans>This is the required scopes you must set in your provider's settings</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="wellKnownUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required={isSsoEnabled}>
|
|
||||||
<Trans>Issuer URL</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder={'https://your-provider.com/.well-known/openid-configuration'}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{!form.formState.errors.wellKnownUrl && (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
<Trans>The OpenID discovery endpoint URL for your provider</Trans>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="clientId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required={isSsoEnabled}>
|
|
||||||
<Trans>Client ID</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input id="client-id" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="clientSecret"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required={isSsoEnabled}>
|
|
||||||
<Trans>Client Secret</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
id="client-secret"
|
|
||||||
type="password"
|
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '**********************' : field.value}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="defaultOrganisationRole"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Default Organisation Role for New Users</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t`Select default role`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[OrganisationMemberRole.MANAGER].map(
|
|
||||||
(role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{t(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="allowedDomains"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Allowed Email Domains</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
{...field}
|
|
||||||
placeholder={t`your-domain.com another-domain.com`}
|
|
||||||
className="min-h-[80px]"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{!form.formState.errors.allowedDomains && (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
<Trans>
|
|
||||||
Space-separated list of domains. Leave empty to allow all domains.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Todo: This is just dummy toggle, we need to decide what this does first. */}
|
|
||||||
{/* <FormField
|
|
||||||
control={form.control}
|
|
||||||
name="autoProvisionUsers"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Auto-provision Users</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans>Automatically create accounts for new users on first login</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Enable SSO portal</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans>Whether to enable the SSO portal for your organisation</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>
|
|
||||||
Please note that anyone who signs in through your portal will be added to your
|
|
||||||
organisation as a member.
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button loading={form.formState.isSubmitting} type="submit">
|
|
||||||
<Trans>Update</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@@ -20,21 +20,28 @@ export default function OrganisationSettingsTeamsPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||||
|
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle debouncing the search query.
|
* Handle debouncing the search query.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
const handleSearchQueryChange = useCallback(
|
||||||
|
(newQuery: string) => {
|
||||||
const params = new URLSearchParams(searchParams?.toString());
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
params.set('query', debouncedSearchQuery);
|
if (newQuery.trim()) {
|
||||||
|
params.set('query', newQuery);
|
||||||
if (debouncedSearchQuery === '') {
|
} else {
|
||||||
params.delete('query');
|
params.delete('query');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchParams(params);
|
setSearchParams(params);
|
||||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentParamQuery = searchParams?.get('query') ?? '';
|
||||||
|
if (currentParamQuery !== debouncedSearchQuery) {
|
||||||
|
handleSearchQueryChange(debouncedSearchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { Outlet, useNavigate } from 'react-router';
|
import { Outlet, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||||
@@ -30,13 +28,8 @@ export default function Layout() {
|
|||||||
const currentOrganisation = organisations[0];
|
const currentOrganisation = organisations[0];
|
||||||
const team = currentOrganisation?.teams[0] || null;
|
const team = currentOrganisation?.teams[0] || null;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPersonalLayoutMode || !team) {
|
if (!isPersonalLayoutMode || !team) {
|
||||||
void navigate('/settings/profile');
|
void navigate('/settings/profile');
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isPersonalLayoutMode || !team) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,27 +192,6 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Alert
|
|
||||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
<div className="mb-4 mr-4 sm:mb-0">
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>Linked Accounts</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
|
|
||||||
<AlertDescription className="mr-2">
|
|
||||||
<Trans>View and manage all login methods linked to your account.</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button asChild variant="outline" className="bg-background">
|
|
||||||
<Link to="/settings/security/linked-accounts">
|
|
||||||
<Trans>Manage linked accounts</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { authClient } from '@documenso/auth/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
|
||||||
|
|
||||||
export function meta() {
|
|
||||||
return appMetaTags('Linked Accounts');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsSecurityLinkedAccounts() {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError, refetch } = useQuery({
|
|
||||||
queryKey: ['linked-accounts'],
|
|
||||||
queryFn: async () => await authClient.account.getMany(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = data?.accounts ?? [];
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: t`Provider`,
|
|
||||||
accessorKey: 'provider',
|
|
||||||
cell: ({ row }) => row.original.provider,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t`Linked At`,
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.createdAt
|
|
||||||
? DateTime.fromJSDate(row.original.createdAt).toRelative()
|
|
||||||
: t`Unknown`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<AccountUnlinkDialog
|
|
||||||
accountId={row.original.id}
|
|
||||||
provider={row.original.provider}
|
|
||||||
onSuccess={refetch}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof results)[number]>[];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title={t`Linked Accounts`}
|
|
||||||
subtitle={t`View and manage all login methods linked to your account.`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results}
|
|
||||||
hasFilters={false}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-40 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-24 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-8 w-16 rounded" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountUnlinkDialogProps = {
|
|
||||||
accountId: string;
|
|
||||||
provider: string;
|
|
||||||
onSuccess: () => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccountUnlinkDialog = ({ accountId, onSuccess, provider }: AccountUnlinkDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleRevoke = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await authClient.account.delete(accountId);
|
|
||||||
|
|
||||||
await onSuccess();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Account unlinked`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Error`,
|
|
||||||
description: t`Failed to unlink account`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="destructive" size="sm">
|
|
||||||
<Trans>Unlink</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to remove the <span className="font-semibold">{provider}</span> login
|
|
||||||
method from your account.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="destructive" loading={isLoading} onClick={handleRevoke}>
|
|
||||||
<Trans>Unlink</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -67,7 +67,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
const documentVisibility = document?.visibility;
|
const documentVisibility = document?.visibility;
|
||||||
const currentTeamMemberRole = team.currentTeamRole;
|
const currentTeamMemberRole = team.currentTeamRole;
|
||||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
let canAccessDocument = true;
|
let canAccessDocument = true;
|
||||||
|
|
||||||
if (!isRecipient && document?.userId !== user.id) {
|
if (!isRecipient && document?.userId !== user.id) {
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.folderId) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
const recipients = await getRecipientsForDocument({
|
const recipients = await getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -64,13 +68,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
recipients,
|
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
|
recipients,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { document, recipients, documentRootPath } = loaderData;
|
const { document, documentRootPath, recipients } = loaderData;
|
||||||
|
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user