mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
Compare commits
34 Commits
exp/effect
...
fix/downlo
| Author | SHA1 | Date | |
|---|---|---|---|
| ffef52565a | |||
| a3005f8616 | |||
| 86fb6de9c3 | |||
| b93b0aae64 | |||
| 7080a36f21 | |||
| 2ae94b1e55 | |||
| 2c0d4f8789 | |||
| 7c8e93b53e | |||
| 93a3809f6a | |||
| 4550bca3d3 | |||
| 9ac7b94d9a | |||
| 374f2c45b4 | |||
| 4bb50487e7 | |||
| bb5c2edefd | |||
| 19565c1821 | |||
| 2603ae8b90 | |||
| 7d257236a6 | |||
| 31c1a9a783 | |||
| 657db3bc84 | |||
| 184ebdedf1 | |||
| 4012022f55 | |||
| 44f5da95b3 | |||
| 7eb882aea8 | |||
| dbf10e5b7b | |||
| fe4d3ed1fd | |||
| b8d07fd1a6 | |||
| 49fabeb0ec | |||
| 5a5bfe6e34 | |||
| d7e5a9eec7 | |||
| adefac81e2 | |||
| 67501b45cf | |||
| 17b36ac8e4 | |||
| 80e452afa2 | |||
| 1cb9de8083 |
57
AGENTS.md
Normal file
57
AGENTS.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# 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,8 +214,6 @@ 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:
|
||||||
@ -258,7 +256,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/web` folder.
|
> 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.
|
||||||
|
|
||||||
### Run as a service
|
### Run as a service
|
||||||
|
|
||||||
@ -308,7 +306,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 Next.js 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 Remix start command
|
||||||
|
|
||||||
For local docker run
|
For local docker run
|
||||||
|
|
||||||
|
|||||||
21
SIGNING.md
21
SIGNING.md
@ -10,15 +10,26 @@ 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 command to do this:
|
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this:
|
||||||
|
|
||||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
```bash
|
||||||
|
# Set certificate password securely (won't appear in command history)
|
||||||
|
read -s -p "Enter certificate password: " CERT_PASS
|
||||||
|
echo
|
||||||
|
|
||||||
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**)
|
# Create the p12 certificate using the environment variable
|
||||||
|
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \
|
||||||
|
-password env:CERT_PASS \
|
||||||
|
-keypbe PBE-SHA1-3DES \
|
||||||
|
-certpbe PBE-SHA1-3DES \
|
||||||
|
-macalg sha1
|
||||||
|
```
|
||||||
|
|
||||||
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
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/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/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61
|
#: apps/remix/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:web
|
npm run build
|
||||||
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/web` folder.
|
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.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
@ -119,16 +119,89 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
|||||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Update the Volume Binding
|
### Set Up Your Signing Certificate
|
||||||
|
|
||||||
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:
|
<Callout type="warning">
|
||||||
|
This is the most common source of issues for self-hosters. Please follow these steps carefully.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
```yaml
|
The `cert.p12` file is required to sign and encrypt documents. You have three options:
|
||||||
volumes:
|
|
||||||
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
|
|
||||||
```
|
|
||||||
|
|
||||||
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
|
#### Option A: Generate Certificate Inside Container (Recommended)
|
||||||
|
|
||||||
|
This method avoids file permission issues by creating the certificate directly inside the Docker container:
|
||||||
|
|
||||||
|
1. Start your containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set certificate password securely and generate certificate inside the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set certificate password securely (won't appear in command history)
|
||||||
|
read -s -p "Enter certificate password: " CERT_PASS
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Generate certificate inside container using environment variable
|
||||||
|
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
|
||||||
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
-keyout /tmp/private.key \
|
||||||
|
-out /tmp/certificate.crt \
|
||||||
|
-subj '/C=US/ST=State/L=City/O=Organization/CN=localhost' && \
|
||||||
|
openssl pkcs12 -export -out /app/certs/cert.p12 \
|
||||||
|
-inkey /tmp/private.key -in /tmp/certificate.crt \
|
||||||
|
-passout env:CERT_PASS && \
|
||||||
|
rm /tmp/private.key /tmp/certificate.crt
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the certificate passphrase to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PRIVATE_SIGNING_PASSPHRASE="your_password_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart the container to apply changes:
|
||||||
|
```bash
|
||||||
|
docker-compose restart documenso
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Use an Existing Certificate File
|
||||||
|
|
||||||
|
If you have an existing `.p12` certificate file:
|
||||||
|
|
||||||
|
1. **Place your certificate file** in an accessible location on your host system
|
||||||
|
2. **Set proper permissions:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make sure the certificate is readable
|
||||||
|
chmod 644 /path/to/your/cert.p12
|
||||||
|
|
||||||
|
# For Docker, ensure proper ownership
|
||||||
|
chown 1001:1001 /path/to/your/cert.p12
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update the volume binding** in the `compose.yml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Add certificate configuration** to your `.env` file:
|
||||||
|
```bash
|
||||||
|
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
|
||||||
|
NEXT_PRIVATE_SIGNING_PASSPHRASE=your_certificate_password
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="warning">
|
||||||
|
Your certificate MUST have a password. Certificates without passwords will cause "Failed to get
|
||||||
|
private key bags" errors.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
After setting up your certificate, save the `compose.yml` file and run the following command to start the containers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env up -d
|
docker-compose --env-file ./.env up -d
|
||||||
@ -249,7 +322,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/web
|
WorkingDirectory=/var/www/documenso/apps/remix
|
||||||
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/ SOC II
|
## SOC 2
|
||||||
|
|
||||||
<Callout type="warning" emoji="⏳">
|
<Callout type="info" emoji="✅">
|
||||||
Status: [Planned](https://github.com/documenso/backlog/issues/24)
|
Status: [Compliant](https://documen.so/trust)
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
SOC II is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
SOC 2 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 for
|
ISO 27001 is an international standard for managing information security, specifying requirements
|
||||||
establishing, implementing, maintaining, and continually improving an information security management
|
for establishing, implementing, maintaining, and continually improving an information security
|
||||||
system (ISMS).
|
management system (ISMS).
|
||||||
|
|
||||||
### HIPAA
|
### HIPAA
|
||||||
|
|
||||||
|
|||||||
@ -3,5 +3,6 @@
|
|||||||
"members": "Members",
|
"members": "Members",
|
||||||
"groups": "Groups",
|
"groups": "Groups",
|
||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
|
"sso": "SSO",
|
||||||
"billing": "Billing"
|
"billing": "Billing"
|
||||||
}
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"index": "Configuration",
|
||||||
|
"microsoft-entra-id": "Microsoft Entra ID"
|
||||||
|
}
|
||||||
149
apps/documentation/pages/users/organisations/sso/index.mdx
Normal file
149
apps/documentation/pages/users/organisations/sso/index.mdx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
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.
|
After 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.deleteDocument.useMutation();
|
trpc.admin.document.delete.useMutation();
|
||||||
|
|
||||||
const handleDeleteDocument = async () => {
|
const handleDeleteDocument = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,17 +96,16 @@ export const AdminOrganisationCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
form.reset();
|
||||||
form.reset();
|
}, [open, form]);
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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: User;
|
user: TGetUserResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
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.deleteUser.useMutation();
|
trpc.admin.user.delete.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: User;
|
userToDisable: TGetUserResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
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.disableUser.useMutation();
|
trpc.admin.user.disable.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: User;
|
userToEnable: TGetUserResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
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.enableUser.useMutation();
|
trpc.admin.user.enable.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: User;
|
user: TGetUserResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserResetTwoFactorDialog = ({
|
export const AdminUserResetTwoFactorDialog = ({
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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.deleteDocument.useMutation({
|
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
@ -73,23 +73,20 @@ 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={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -36,11 +36,12 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
||||||
{
|
{
|
||||||
documentId: id,
|
documentId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
queryHash: `document-duplicate-dialog-${id}`,
|
||||||
enabled: open === true,
|
enabled: open === true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -55,7 +56,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.duplicateDocument.useMutation({
|
trpcReact.document.duplicate.useMutation({
|
||||||
onSuccess: async ({ documentId }) => {
|
onSuccess: async ({ documentId }) => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,6 +83,15 @@ 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({
|
||||||
@ -136,22 +145,12 @@ 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={handleOpenChange}>
|
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||||
<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.resendDocument.useMutation();
|
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
|
||||||
|
|
||||||
const form = useForm<TResendDocumentFormSchema>({
|
const form = useForm<TResendDocumentFormSchema>({
|
||||||
resolver: zodResolver(ZResendDocumentFormSchema),
|
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,15 +80,14 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!isCreateFolderOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
setIsCreateFolderOpen(value);
|
}, [isCreateFolderOpen, form]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={handleOpenChange}>
|
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
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';
|
||||||
@ -90,15 +92,14 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!isOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
onOpenChange(value);
|
}, [isOpen]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,13 +97,12 @@ export const FolderMoveDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!isOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
}
|
}
|
||||||
onOpenChange(value);
|
}, [isOpen, form]);
|
||||||
};
|
|
||||||
|
|
||||||
// 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(
|
||||||
@ -114,7 +113,7 @@ export const FolderMoveDialog = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
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';
|
||||||
@ -69,6 +71,15 @@ 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;
|
||||||
@ -99,18 +110,8 @@ 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={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<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(actionSearchParam === 'add-organisation');
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||||
@ -91,19 +91,6 @@ 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({
|
||||||
@ -139,6 +126,17 @@ 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 ||
|
||||||
@ -147,7 +145,11 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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">
|
||||||
@ -312,16 +314,13 @@ const BillingPlanForm = ({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [plans, t]);
|
}, [plans]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value === '' && !canCreateFreeOrganisation && dynamicPlans.length > 0) {
|
if (value === '' && !canCreateFreeOrganisation) {
|
||||||
const defaultValue = dynamicPlans[0][billingPeriod]?.id ?? '';
|
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
|
||||||
if (defaultValue) {
|
|
||||||
onChange(defaultValue);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [canCreateFreeOrganisation, dynamicPlans, billingPeriod, onChange, value]);
|
}, [value]);
|
||||||
|
|
||||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
||||||
const plan = dynamicPlans.find(
|
const plan = dynamicPlans.find(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,19 +93,14 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (form.formState.isSubmitting) {
|
if (!open) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
setOpen(value);
|
}, [open, form]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,6 +73,13 @@ 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({
|
||||||
@ -107,17 +114,8 @@ export const OrganisationEmailCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
if (!isPending) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && 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">
|
||||||
|
|||||||
@ -36,12 +36,6 @@ 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: () => {
|
||||||
@ -64,7 +58,7 @@ export const OrganisationEmailDeleteDialog = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,6 +75,14 @@ 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({
|
||||||
@ -110,18 +118,12 @@ export const OrganisationEmailDomainCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
setStep('domain');
|
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useState } from 'react';
|
import { useEffect, 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,20 +87,23 @@ export const OrganisationEmailUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (!open) {
|
||||||
form.reset({
|
return;
|
||||||
emailName: organisationEmail.emailName,
|
|
||||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
form.reset({
|
||||||
}
|
emailName: organisationEmail.emailName,
|
||||||
};
|
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||||
|
});
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useState } from 'react';
|
import { useEffect, 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,17 +117,16 @@ export const OrganisationGroupCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
form.reset();
|
||||||
form.reset();
|
}, [open, form]);
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useMemo, useRef, useState } from 'react';
|
import { useEffect, 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,6 +193,13 @@ 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;
|
||||||
@ -260,18 +267,12 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
setInvitationType('INDIVIDUAL');
|
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useState } from 'react';
|
import { useEffect, 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,27 +106,32 @@ export const OrganisationMemberUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (!open) {
|
||||||
form.reset();
|
return;
|
||||||
if (
|
|
||||||
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
|
||||||
) {
|
|
||||||
setOpen(false);
|
|
||||||
toast({
|
|
||||||
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
form.reset();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useState } from 'react';
|
import { useEffect, 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.createPasskeyRegistrationOptions.useMutation();
|
trpc.auth.passkey.createRegistrationOptions.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
|
const { mutateAsync: createPasskey } = trpc.auth.passkey.create.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@ -120,21 +120,24 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
|||||||
return passkeyName;
|
return passkeyName;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
const defaultPasskeyName = extractDefaultPasskeyName();
|
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
passkeyName: defaultPasskeyName,
|
passkeyName: defaultPasskeyName,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleDialogOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useMemo, useState } from 'react';
|
import { useEffect, 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 actionSearchParam = searchParams?.get('action');
|
const [open, setOpen] = useState(false);
|
||||||
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,18 +85,6 @@ 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({
|
||||||
@ -162,8 +150,23 @@ 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 {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useState } from 'react';
|
import { useEffect, 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,17 +114,14 @@ export const TeamDeleteDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,17 +103,18 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useState } from 'react';
|
import { useEffect, 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,17 +92,18 @@ export const TeamEmailUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useMemo, useState } from 'react';
|
import { useEffect, 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
handleClose();
|
setOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: t`An unknown error occurred`,
|
title: t`An unknown error occurred`,
|
||||||
@ -117,23 +117,17 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
useEffect(() => {
|
||||||
setOpen(false);
|
if (!open) {
|
||||||
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 { useState } from 'react';
|
import { useEffect, 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,17 +106,22 @@ export const TeamGroupUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!form.formState.isSubmitting) {
|
if (!open) {
|
||||||
if (value) {
|
return;
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
setOpen(value);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useMemo, useState } from 'react';
|
import { useEffect, 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,17 +119,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
setStep('SELECT');
|
setStep('SELECT');
|
||||||
}
|
}
|
||||||
// Disable automatic onOpenChange events to prevent dialog from closing if user 'accidentally' clicks the overlay.
|
}, [open, form]);
|
||||||
// Since it would be annoying to redo the whole process, we handle open state manually
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||||
|
// Since it would be annoying to redo the whole process.
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<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 { useState } from 'react';
|
import { useEffect, 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,25 +106,30 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (!open) {
|
||||||
form.reset();
|
return;
|
||||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
|
||||||
setOpen(false);
|
|
||||||
toast({
|
|
||||||
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
form.reset();
|
||||||
|
|
||||||
|
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...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 { useMemo, useState } from 'react';
|
import { useEffect, 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,20 +186,16 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
const isLoading =
|
const isLoading =
|
||||||
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!isLoading) {
|
resetCreateTemplateDirectLink();
|
||||||
if (value) {
|
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||||
resetCreateTemplateDirectLink();
|
setSelectedRecipientId(null);
|
||||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
|
||||||
setSelectedRecipientId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(value);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}
|
}, [open]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<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 { useState } from 'react';
|
import { useEffect, 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,6 +85,15 @@ 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({
|
||||||
@ -128,22 +137,12 @@ 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={handleOpenChange}>
|
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,17 +203,14 @@ export function TemplateUseDialog({
|
|||||||
name: 'recipients',
|
name: 'recipients',
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (form.formState.isSubmitting) return;
|
if (!open) {
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
setOpen(value);
|
}, [open, form]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger || (
|
{trigger || (
|
||||||
<Button variant="outline" className="bg-background">
|
<Button variant="outline" className="bg-background">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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.deleteTokenById.useMutation({
|
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
onDelete?.();
|
onDelete?.();
|
||||||
},
|
},
|
||||||
@ -95,17 +95,17 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!isOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [isOpen, form]);
|
||||||
setIsOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
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 { useState } from 'react';
|
import { useEffect, 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,17 +88,14 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<Button className="mr-4" variant="destructive">
|
<Button className="mr-4" variant="destructive">
|
||||||
|
|||||||
@ -172,6 +172,8 @@ 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 ?? {};
|
||||||
@ -540,7 +542,9 @@ export const ConfigureFieldsView = ({
|
|||||||
<div>
|
<div>
|
||||||
<PDFViewer documentData={normalizedDocumentData} />
|
<PDFViewer documentData={normalizedDocumentData} />
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
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,8 +91,12 @@ 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();
|
||||||
|
|
||||||
@ -343,19 +347,34 @@ 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" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
) : pendingFields.length > 0 ? (
|
||||||
onClick={() => setIsExpanded(true)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
)}
|
className="h-8 w-8 p-0 md:hidden"
|
||||||
</Button>
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="md:hidden"
|
||||||
|
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={() => throttledOnCompleteClick()}
|
||||||
|
>
|
||||||
|
<Trans>Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -442,7 +461,9 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
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,8 +50,10 @@ 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}>
|
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||||
{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,6 +106,8 @@ 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();
|
||||||
|
|
||||||
@ -116,6 +118,8 @@ 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 = () => {
|
||||||
@ -305,19 +309,36 @@ 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" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
) : pendingFields.length > 0 ? (
|
||||||
onClick={() => setIsExpanded(true)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
)}
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
</Button>
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="md:hidden"
|
||||||
|
disabled={
|
||||||
|
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||||
|
}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={() => throttledOnCompleteClick()}
|
||||||
|
>
|
||||||
|
<Trans>Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -465,7 +486,9 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
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,10 +1,12 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } 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) {
|
if (window.parent && !hasPostedMessage) {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
action: 'document-waiting-for-turn',
|
action: 'document-waiting-for-turn',
|
||||||
@ -13,7 +15,13 @@ 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,6 +92,8 @@ 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) => {
|
||||||
@ -357,7 +359,9 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasDocumentLoaded && (
|
{hasDocumentLoaded && (
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
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 { useState } from 'react';
|
import { useEffect, 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,19 +136,18 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
useEffect(() => {
|
||||||
setIsOpen(open);
|
enable2FAForm.reset();
|
||||||
|
|
||||||
if (!open) {
|
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||||
enable2FAForm.reset();
|
setRecoveryCodes(null);
|
||||||
if (recoveryCodes && recoveryCodes.length > 0) {
|
|
||||||
setRecoveryCodes(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild={true}>
|
<DialogTrigger asChild={true}>
|
||||||
<Button
|
<Button
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, 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,20 +114,11 @@ export const SignInForm = ({
|
|||||||
}, [returnTo]);
|
}, [returnTo]);
|
||||||
|
|
||||||
const { mutateAsync: createPasskeySigninOptions } =
|
const { mutateAsync: createPasskeySigninOptions } =
|
||||||
trpc.auth.createPasskeySigninOptions.useMutation();
|
trpc.auth.passkey.createSigninOptions.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: emailFromHash ?? initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
totpCode: '',
|
totpCode: '',
|
||||||
backupCode: '',
|
backupCode: '',
|
||||||
@ -296,6 +287,18 @@ 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 { useMemo } from 'react';
|
import { useEffect } 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,19 +84,10 @@ 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: emailFromHash ?? initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
signature: '',
|
||||||
},
|
},
|
||||||
@ -171,6 +162,18 @@ 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 { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
|
||||||
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 = ZCreateTokenMutationSchema.pick({
|
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.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.createToken.useMutation({
|
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
setNewlyCreatedToken(data);
|
setNewlyCreatedToken(data);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
'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.searchDocuments.useQuery(
|
trpcReact.document.search.useQuery(
|
||||||
{
|
{
|
||||||
query: search,
|
query: search,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -79,6 +79,8 @@ 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]);
|
||||||
@ -221,7 +223,9 @@ export const DirectTemplateSigningForm = ({
|
|||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
|
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
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.createPasskeyAuthenticationOptions.useMutation();
|
trpc.auth.passkey.createAuthenticationOptions.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.findPasskeys.useQuery(
|
const passkeyQuery = trpc.auth.passkey.find.useQuery(
|
||||||
{
|
{
|
||||||
perPage: MAXIMUM_PASSKEYS,
|
perPage: MAXIMUM_PASSKEYS,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -9,6 +9,7 @@ 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';
|
||||||
@ -60,6 +61,12 @@ 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;
|
||||||
@ -88,14 +95,6 @@ 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) => {
|
||||||
@ -153,6 +152,12 @@ 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,14 +7,11 @@ 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, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition } 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';
|
||||||
@ -34,29 +31,33 @@ 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();
|
||||||
|
|
||||||
@ -66,21 +67,12 @@ 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],
|
||||||
@ -96,9 +88,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 fieldsValidated = () => {
|
const localFieldsValidated = () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
validateFieldsInserted(fieldsRequiringValidation);
|
fieldsValidated();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
@ -126,55 +118,6 @@ 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] && (
|
||||||
@ -205,7 +148,7 @@ export const DocumentSigningForm = ({
|
|||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) => {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument(undefined, nextSigner);
|
||||||
}}
|
}}
|
||||||
@ -364,7 +307,7 @@ export const DocumentSigningForm = ({
|
|||||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) => {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument(undefined, nextSigner);
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, 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 { match } from 'ts-pattern';
|
import { useNavigate } from 'react-router';
|
||||||
|
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,
|
||||||
@ -18,8 +21,11 @@ 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';
|
||||||
@ -40,6 +46,7 @@ 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 = {
|
||||||
@ -63,9 +70,56 @@ 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})`;
|
||||||
|
|
||||||
@ -78,6 +132,31 @@ 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">
|
||||||
@ -163,19 +242,55 @@ export const DocumentSigningPageView = ({
|
|||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
{match({ hasPendingFields, isExpanded, role: recipient.role })
|
||||||
{isExpanded ? (
|
.with(
|
||||||
<LucideChevronDown
|
{
|
||||||
className="text-muted-foreground h-5 w-5"
|
hasPendingFields: false,
|
||||||
|
role: P.not(RecipientRole.ASSISTANT),
|
||||||
|
isExpanded: false,
|
||||||
|
},
|
||||||
|
() => (
|
||||||
|
<div className="md:hidden">
|
||||||
|
<DocumentSigningCompleteDialog
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
documentTitle={document.title}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
disabled={!isRecipientsTurn}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
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" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
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">
|
||||||
@ -204,10 +319,13 @@ 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>
|
||||||
@ -224,7 +342,9 @@ export const DocumentSigningPageView = ({
|
|||||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||||
|
>
|
||||||
{fields
|
{fields
|
||||||
.filter(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, 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,16 +80,20 @@ 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 = useMemo(
|
const shouldAutoSignField =
|
||||||
() =>
|
(!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);
|
||||||
@ -212,12 +216,14 @@ export const DocumentSigningTextField = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shouldAutoSignField) {
|
useEffect(() => {
|
||||||
void executeActionAuthProcedure({
|
if (shouldAutoSignField) {
|
||||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
void executeActionAuthProcedure({
|
||||||
actionTarget: field.type,
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||||
});
|
actionTarget: field.type,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
||||||
|
|
||||||
@ -312,8 +318,7 @@ export const DocumentSigningTextField = ({
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCustomTextModal(false);
|
setShowCustomTextModal(false);
|
||||||
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
setLocalCustomText('');
|
||||||
setErrors(initialErrors);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
|
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 { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentAuditLogDownloadButtonProps = {
|
export type DocumentAuditLogDownloadButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -19,44 +22,38 @@ export const DocumentAuditLogDownloadButton = ({
|
|||||||
}: DocumentAuditLogDownloadButtonProps) => {
|
}: DocumentAuditLogDownloadButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
const { mutateAsync: downloadAuditLogs, isPending } =
|
const team = useCurrentTeam();
|
||||||
trpc.document.downloadAuditLogs.useMutation();
|
|
||||||
|
|
||||||
const onDownloadAuditLogsClick = async () => {
|
const onDownloadAuditLogsClick = async () => {
|
||||||
|
setIsPending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url } = await downloadAuditLogs({ documentId });
|
const response = await fetch(`/api/t/${team.url}/download/audit-logs/${documentId}`);
|
||||||
|
|
||||||
const iframe = Object.assign(document.createElement('iframe'), {
|
if (!response.ok) {
|
||||||
src: url,
|
throw new Error('Failed to download certificate');
|
||||||
});
|
}
|
||||||
|
|
||||||
Object.assign(iframe.style, {
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
position: 'fixed',
|
const filename =
|
||||||
top: '0',
|
contentDisposition?.split('filename="')[1]?.split('"')[0] ||
|
||||||
left: '0',
|
`document_${documentId}_audit_logs.pdf`;
|
||||||
width: '0',
|
|
||||||
height: '0',
|
|
||||||
});
|
|
||||||
|
|
||||||
const onLoaded = () => {
|
const blob = await response.blob();
|
||||||
if (iframe.contentDocument?.readyState === 'complete') {
|
const url = URL.createObjectURL(blob);
|
||||||
iframe.contentWindow?.print();
|
const link = document.createElement('a');
|
||||||
|
|
||||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
link.href = url;
|
||||||
document.body.removeChild(iframe);
|
link.download = filename;
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
document.body.appendChild(link);
|
||||||
iframe.addEventListener('load', onLoaded);
|
link.click();
|
||||||
|
|
||||||
document.body.appendChild(iframe);
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
onLoaded();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error('Audit logs download error:', error);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -65,6 +62,8 @@ export const DocumentAuditLogDownloadButton = ({
|
|||||||
),
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
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';
|
||||||
@ -5,11 +7,12 @@ import type { DocumentStatus } from '@prisma/client';
|
|||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentCertificateDownloadButtonProps = {
|
export type DocumentCertificateDownloadButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -23,44 +26,38 @@ export const DocumentCertificateDownloadButton = ({
|
|||||||
}: DocumentCertificateDownloadButtonProps) => {
|
}: DocumentCertificateDownloadButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
const { mutateAsync: downloadCertificate, isPending } =
|
const team = useCurrentTeam();
|
||||||
trpc.document.downloadCertificate.useMutation();
|
|
||||||
|
|
||||||
const onDownloadCertificatesClick = async () => {
|
const onDownloadCertificatesClick = async () => {
|
||||||
|
setIsPending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url } = await downloadCertificate({ documentId });
|
const response = await fetch(`/api/t/${team.url}/download/certificate/${documentId}`);
|
||||||
|
|
||||||
const iframe = Object.assign(document.createElement('iframe'), {
|
if (!response.ok) {
|
||||||
src: url,
|
throw new Error('Failed to download certificate');
|
||||||
});
|
}
|
||||||
|
|
||||||
Object.assign(iframe.style, {
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
position: 'fixed',
|
const filename =
|
||||||
top: '0',
|
contentDisposition?.split('filename="')[1]?.split('"')[0] ||
|
||||||
left: '0',
|
`document_${documentId}_certificate.pdf`;
|
||||||
width: '0',
|
|
||||||
height: '0',
|
|
||||||
});
|
|
||||||
|
|
||||||
const onLoaded = () => {
|
const blob = await response.blob();
|
||||||
if (iframe.contentDocument?.readyState === 'complete') {
|
const url = URL.createObjectURL(blob);
|
||||||
iframe.contentWindow?.print();
|
const link = document.createElement('a');
|
||||||
|
|
||||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
link.href = url;
|
||||||
document.body.removeChild(iframe);
|
link.download = filename;
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
document.body.appendChild(link);
|
||||||
iframe.addEventListener('load', onLoaded);
|
link.click();
|
||||||
|
|
||||||
document.body.appendChild(iframe);
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
onLoaded();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error('Certificate download error:', error);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -69,6 +66,8 @@ export const DocumentCertificateDownloadButton = ({
|
|||||||
),
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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.createDocument.useMutation();
|
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
||||||
|
|
||||||
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||||
|
|
||||||
|
|||||||
@ -59,23 +59,22 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const { data: document, refetch: refetchDocument } =
|
const { data: document, refetch: refetchDocument } = trpc.document.get.useQuery(
|
||||||
trpc.document.getDocumentWithDetailsById.useQuery(
|
{
|
||||||
{
|
documentId: initialDocument.id,
|
||||||
documentId: initialDocument.id,
|
},
|
||||||
},
|
{
|
||||||
{
|
initialData: initialDocument,
|
||||||
initialData: initialDocument,
|
...SKIP_QUERY_BATCH_META,
|
||||||
...SKIP_QUERY_BATCH_META,
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const { recipients, fields } = document;
|
const { recipients, fields } = document;
|
||||||
|
|
||||||
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
|
const { mutateAsync: updateDocument } = trpc.document.update.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
utils.document.get.setData(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
documentId: initialDocument.id,
|
||||||
},
|
},
|
||||||
@ -84,23 +83,10 @@ 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.getDocumentWithDetailsById.setData(
|
utils.document.get.setData(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
documentId: initialDocument.id,
|
||||||
},
|
},
|
||||||
@ -112,7 +98,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.getDocumentWithDetailsById.setData(
|
utils.document.get.setData(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
documentId: initialDocument.id,
|
||||||
},
|
},
|
||||||
@ -121,10 +107,10 @@ export const DocumentEditForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({
|
const { mutateAsync: sendDocument } = trpc.document.distribute.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
utils.document.get.setData(
|
||||||
{
|
{
|
||||||
documentId: initialDocument.id,
|
documentId: initialDocument.id,
|
||||||
},
|
},
|
||||||
@ -173,34 +159,37 @@ export const DocumentEditForm = ({
|
|||||||
return initialStep;
|
return initialStep;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveSettingsData = async (data: TAddSettingsFormSchema) => {
|
||||||
|
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||||
|
|
||||||
|
const parsedGlobalAccessAuth = z
|
||||||
|
.array(ZDocumentAccessAuthTypesSchema)
|
||||||
|
.safeParse(data.globalAccessAuth);
|
||||||
|
|
||||||
|
return updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
externalId: data.externalId || null,
|
||||||
|
visibility: data.visibility,
|
||||||
|
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||||
|
globalActionAuth: data.globalActionAuth ?? [],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timezone,
|
||||||
|
dateFormat,
|
||||||
|
redirectUrl,
|
||||||
|
language: isValidLanguageCode(language) ? language : undefined,
|
||||||
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
await saveSettingsData(data);
|
||||||
|
|
||||||
const parsedGlobalAccessAuth = z
|
|
||||||
.array(ZDocumentAccessAuthTypesSchema)
|
|
||||||
.safeParse(data.globalAccessAuth);
|
|
||||||
|
|
||||||
await updateDocument({
|
|
||||||
documentId: document.id,
|
|
||||||
data: {
|
|
||||||
title: data.title,
|
|
||||||
externalId: data.externalId || null,
|
|
||||||
visibility: data.visibility,
|
|
||||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
|
||||||
globalActionAuth: data.globalActionAuth ?? [],
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
timezone,
|
|
||||||
dateFormat,
|
|
||||||
redirectUrl,
|
|
||||||
language: isValidLanguageCode(language) ? language : undefined,
|
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setStep('signers');
|
setStep('signers');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -213,30 +202,58 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => {
|
||||||
|
try {
|
||||||
|
await saveSettingsData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`An error occurred while auto-saving the document settings.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSignersData = async (data: TAddSignersFormSchema) => {
|
||||||
|
return Promise.all([
|
||||||
|
updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
meta: {
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
|
signingOrder: data.signingOrder,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
setRecipients({
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: data.signers.map((signer) => ({
|
||||||
|
...signer,
|
||||||
|
// Explicitly set to null to indicate we want to remove auth if required.
|
||||||
|
actionAuth: signer.actionAuth ?? [],
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
|
||||||
|
try {
|
||||||
|
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) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await saveSignersData(data);
|
||||||
setSigningOrderForDocument({
|
|
||||||
documentId: document.id,
|
|
||||||
signingOrder: data.signingOrder,
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateDocument({
|
|
||||||
documentId: document.id,
|
|
||||||
meta: {
|
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
setRecipients({
|
|
||||||
documentId: document.id,
|
|
||||||
recipients: data.signers.map((signer) => ({
|
|
||||||
...signer,
|
|
||||||
// Explicitly set to null to indicate we want to remove auth if required.
|
|
||||||
actionAuth: signer.actionAuth ?? [],
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -250,12 +267,16 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
||||||
|
return addFields({
|
||||||
|
documentId: document.id,
|
||||||
|
fields: data.fields,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await addFields({
|
await saveFieldsData(data);
|
||||||
documentId: document.id,
|
|
||||||
fields: data.fields,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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++) {
|
||||||
@ -277,24 +298,60 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
const onAddFieldsFormAutoSave = async (data: TAddFieldsFormSchema) => {
|
||||||
|
try {
|
||||||
|
await saveFieldsData(data);
|
||||||
|
// Don't clear localStorage on auto-save, only on explicit submit
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`An error occurred while auto-saving the fields.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSubjectData = async (data: TAddSubjectFormSchema) => {
|
||||||
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||||
data.meta;
|
data.meta;
|
||||||
|
|
||||||
try {
|
return updateDocument({
|
||||||
await sendDocument({
|
documentId: document.id,
|
||||||
documentId: document.id,
|
meta: {
|
||||||
meta: {
|
subject,
|
||||||
subject,
|
message,
|
||||||
message,
|
distributionMethod,
|
||||||
distributionMethod,
|
emailId,
|
||||||
emailId,
|
emailReplyTo,
|
||||||
emailReplyTo: emailReplyTo || null,
|
emailSettings: emailSettings,
|
||||||
emailSettings: emailSettings,
|
},
|
||||||
},
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
|
const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => {
|
||||||
|
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||||
|
data.meta;
|
||||||
|
|
||||||
|
return sendDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
meta: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
distributionMethod,
|
||||||
|
emailId,
|
||||||
|
emailReplyTo: emailReplyTo || null,
|
||||||
|
emailSettings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
|
try {
|
||||||
|
await sendDocumentWithSubject(data);
|
||||||
|
|
||||||
|
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.`),
|
||||||
@ -322,6 +379,21 @@ 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];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -367,25 +439,28 @@ export const DocumentEditForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
|
onAutoSave={onAddSettingsFormAutoSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={document.id}
|
||||||
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={fields.length}
|
key={document.id}
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
@ -397,6 +472,7 @@ 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.getDocumentById.query(
|
const documentWithData = await trpcClient.document.get.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.getDocumentById.query(
|
const documentWithData = await trpcClient.document.get.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.getDocumentById.query(
|
const documentWithData = await trpcClient.document.get.query(
|
||||||
{
|
{
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
} = trpc.document.auditLog.find.useInfiniteQuery(
|
||||||
{
|
{
|
||||||
documentId,
|
documentId,
|
||||||
filterForRecentActivity: true,
|
filterForRecentActivity: true,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, 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,13 +26,16 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
|
|||||||
|
|
||||||
setSearchParams(params);
|
setSearchParams(params);
|
||||||
},
|
},
|
||||||
[searchParams, setSearchParams],
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentQuery = searchParams?.get('query') ?? '';
|
useEffect(() => {
|
||||||
if (currentQuery !== debouncedSearchTerm) {
|
const currentQueryParam = searchParams.get('query') || '';
|
||||||
handleSearch(debouncedSearchTerm);
|
|
||||||
}
|
if (debouncedSearchTerm !== currentQueryParam) {
|
||||||
|
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.createDocument.useMutation();
|
const { mutateAsync: createDocument } = trpc.document.create.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.updateDocument.useMutation();
|
trpc.document.update.useMutation();
|
||||||
|
|
||||||
const onUpdateFieldsClick = async () => {
|
const onUpdateFieldsClick = async () => {
|
||||||
if (type === 'document') {
|
if (type === 'document') {
|
||||||
|
|||||||
@ -124,32 +124,36 @@ export const TemplateEditForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
const saveSettingsData = 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({
|
||||||
|
templateId: template.id,
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
externalId: data.externalId || null,
|
||||||
|
visibility: data.visibility,
|
||||||
|
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||||
|
globalActionAuth: data.globalActionAuth ?? [],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
...data.meta,
|
||||||
|
emailReplyTo: data.meta.emailReplyTo || null,
|
||||||
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await updateTemplateSettings({
|
await saveSettingsData(data);
|
||||||
templateId: template.id,
|
|
||||||
data: {
|
|
||||||
title: data.title,
|
|
||||||
externalId: data.externalId || null,
|
|
||||||
visibility: data.visibility,
|
|
||||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
|
||||||
globalActionAuth: data.globalActionAuth ?? [],
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
...data.meta,
|
|
||||||
emailReplyTo: data.meta.emailReplyTo || null,
|
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
|
||||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setStep('signers');
|
setStep('signers');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -163,24 +167,42 @@ export const TemplateEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
|
||||||
|
try {
|
||||||
|
await saveSettingsData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`An error occurred while auto-saving the template settings.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
|
||||||
|
return Promise.all([
|
||||||
|
updateTemplateSettings({
|
||||||
|
templateId: template.id,
|
||||||
|
meta: {
|
||||||
|
signingOrder: data.signingOrder,
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
setRecipients({
|
||||||
|
templateId: template.id,
|
||||||
|
recipients: data.signers,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const onAddTemplatePlaceholderFormSubmit = async (
|
const onAddTemplatePlaceholderFormSubmit = async (
|
||||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await saveTemplatePlaceholderData(data);
|
||||||
updateTemplateSettings({
|
|
||||||
templateId: template.id,
|
|
||||||
meta: {
|
|
||||||
signingOrder: data.signingOrder,
|
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
setRecipients({
|
|
||||||
templateId: template.id,
|
|
||||||
recipients: data.signers,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -192,12 +214,46 @@ export const TemplateEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddTemplatePlaceholderFormAutoSave = async (
|
||||||
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await saveTemplatePlaceholderData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`An error occurred while auto-saving the template placeholders.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
|
||||||
|
return addTemplateFields({
|
||||||
|
templateId: template.id,
|
||||||
|
fields: data.fields,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormSchema) => {
|
||||||
|
try {
|
||||||
|
await saveFieldsData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`An error occurred while auto-saving the template fields.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await addTemplateFields({
|
await saveFieldsData(data);
|
||||||
templateId: template.id,
|
|
||||||
fields: data.fields,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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++) {
|
||||||
@ -270,11 +326,12 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
|
onAutoSave={onAddSettingsFormAutoSave}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTemplatePlaceholderRecipientsFormPartial
|
<AddTemplatePlaceholderRecipientsFormPartial
|
||||||
key={recipients.length}
|
key={template.id}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
@ -282,15 +339,17 @@ 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={fields.length}
|
key={template.id}
|
||||||
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.findDocuments.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.document.find.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.findDocuments.useQuery({
|
const { data, isLoading, isLoadingError, refetch } = trpc.document.find.useQuery({
|
||||||
templateId,
|
templateId,
|
||||||
orderByColumn: 'createdAt',
|
orderByColumn: 'createdAt',
|
||||||
orderByDirection: 'asc',
|
orderByDirection: 'asc',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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,34 +22,11 @@ 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(shouldShowDialog);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
|
||||||
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||||
@ -85,6 +62,27 @@ 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.updateRecipient.useMutation();
|
const { mutateAsync: updateRecipient } = trpc.admin.recipient.update.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.findDocumentAuditLogs.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.document.auditLog.find.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.getDocumentById.query(
|
? await trpcClient.document.get.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.getDocumentById.query({
|
? await trpcClient.document.get.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.getDocumentById.query({
|
? await trpcClient.document.get.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/schema';
|
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
|
||||||
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,7 +17,6 @@ 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';
|
||||||
@ -32,12 +31,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?: TFindDocumentsResponse;
|
data?: TFindInboxResponse;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isLoadingError?: boolean;
|
isLoadingError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
type DocumentsTableRow = TFindInboxResponse['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.updatePasskey.useMutation({
|
trpc.auth.passkey.update.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.deletePasskey.useMutation({
|
trpc.auth.passkey.delete.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.findPasskeys.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.auth.passkey.find.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.resealDocument.useMutation({
|
trpc.admin.document.reseal.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.findDocuments.useQuery(
|
trpc.admin.document.find.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 { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema';
|
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||||
|
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 = ZAdminUpdateProfileMutationSchema.omit({ id: true });
|
const ZUserFormSchema = ZUpdateUserRequestSchema.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.profile.getUser.useQuery(
|
const { data: user, isLoading: isLoadingUser } = trpc.admin.user.get.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: User }) => {
|
const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
||||||
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.updateUser.useMutation();
|
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<TUserFormSchema>({
|
const form = useForm<TUserFormSchema>({
|
||||||
resolver: zodResolver(ZUserFormSchema),
|
resolver: zodResolver(ZUserFormSchema),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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';
|
||||||
@ -77,6 +78,11 @@ 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`,
|
||||||
@ -94,6 +100,13 @@ 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 { useCallback, useState } from 'react';
|
import { useEffect, 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,29 +29,22 @@ export default function TeamsSettingsMembersPage() {
|
|||||||
/**
|
/**
|
||||||
* Handle debouncing the search query.
|
* Handle debouncing the search query.
|
||||||
*/
|
*/
|
||||||
const handleSearchQueryChange = useCallback(
|
useEffect(() => {
|
||||||
(newQuery: string) => {
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
const params = new URLSearchParams(searchParams?.toString());
|
|
||||||
|
|
||||||
if (newQuery.trim()) {
|
params.set('query', debouncedSearchQuery);
|
||||||
params.set('query', newQuery);
|
|
||||||
} else {
|
|
||||||
params.delete('query');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.toString() === searchParams?.toString()) {
|
if (debouncedSearchQuery === '') {
|
||||||
return;
|
params.delete('query');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchParams(params);
|
// If nothing to change then do nothing.
|
||||||
},
|
if (params.toString() === searchParams?.toString()) {
|
||||||
[searchParams, setSearchParams],
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
const currentParamQuery = searchParams?.get('query') ?? '';
|
setSearchParams(params);
|
||||||
if (currentParamQuery !== debouncedSearchQuery) {
|
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||||
handleSearchQueryChange(debouncedSearchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
432
apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
Normal file
432
apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
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 { useCallback, useState } from 'react';
|
import { useEffect, 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,28 +20,21 @@ 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.
|
||||||
*/
|
*/
|
||||||
const handleSearchQueryChange = useCallback(
|
useEffect(() => {
|
||||||
(newQuery: string) => {
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
const params = new URLSearchParams(searchParams?.toString());
|
|
||||||
|
|
||||||
if (newQuery.trim()) {
|
params.set('query', debouncedSearchQuery);
|
||||||
params.set('query', newQuery);
|
|
||||||
} else {
|
|
||||||
params.delete('query');
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearchParams(params);
|
if (debouncedSearchQuery === '') {
|
||||||
},
|
params.delete('query');
|
||||||
[searchParams, setSearchParams],
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const currentParamQuery = searchParams?.get('query') ?? '';
|
setSearchParams(params);
|
||||||
if (currentParamQuery !== debouncedSearchQuery) {
|
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||||
handleSearchQueryChange(debouncedSearchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
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';
|
||||||
@ -28,8 +30,13 @@ 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) {
|
||||||
|
void navigate('/settings/profile');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!isPersonalLayoutMode || !team) {
|
if (!isPersonalLayoutMode || !team) {
|
||||||
void navigate('/settings/profile');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -192,6 +192,27 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,179 @@
|
|||||||
|
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,6 +67,7 @@ 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) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user