mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
19 Commits
v1.12.2-rc
...
feat/impro
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b61c6c07 | |||
| e49910c8ef | |||
| a4fe4b4951 | |||
| ed4dfc9b55 | |||
| 32ce573de4 | |||
| 2ecfdbdde5 | |||
| a3005f8616 | |||
| 2c0d4f8789 | |||
| 7c8e93b53e | |||
| 93a3809f6a | |||
| 4550bca3d3 | |||
| 9ac7b94d9a | |||
| 374f2c45b4 | |||
| bb5c2edefd | |||
| 19565c1821 | |||
| 2603ae8b90 | |||
| 7d257236a6 | |||
| 31c1a9a783 | |||
| 657db3bc84 |
19
SIGNING.md
19
SIGNING.md
@ -10,13 +10,24 @@ For the digital signature of your documents you need a signing certificate in .p
|
||||
|
||||
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
||||
|
||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid.
|
||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid.
|
||||
|
||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following 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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
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**)
|
||||
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)
|
||||
|
||||
|
||||
@ -119,16 +119,89 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
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
|
||||
volumes:
|
||||
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
|
||||
```
|
||||
The `cert.p12` file is required to sign and encrypt documents. You have three options:
|
||||
|
||||
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
|
||||
docker-compose --env-file ./.env up -d
|
||||
|
||||
@ -19,13 +19,13 @@ device, and other FDA-regulated industries.
|
||||
- [x] User Access Management
|
||||
- [x] Quality Assurance Documentation
|
||||
|
||||
## SOC/ SOC II
|
||||
## SOC 2
|
||||
|
||||
<Callout type="warning" emoji="⏳">
|
||||
Status: [Planned](https://github.com/documenso/backlog/issues/24)
|
||||
<Callout type="info" emoji="✅">
|
||||
Status: [Compliant](https://documen.so/trust)
|
||||
</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
|
||||
Public Accountants (AICPA).
|
||||
|
||||
@ -34,9 +34,9 @@ Public Accountants (AICPA).
|
||||
<Callout type="warning" emoji="⏳">
|
||||
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
||||
</Callout>
|
||||
ISO 27001 is an international standard for managing information security, specifying requirements for
|
||||
establishing, implementing, maintaining, and continually improving an information security management
|
||||
system (ISMS).
|
||||
ISO 27001 is an international standard for managing information security, specifying requirements
|
||||
for establishing, implementing, maintaining, and continually improving an information security
|
||||
management system (ISMS).
|
||||
|
||||
### HIPAA
|
||||
|
||||
|
||||
@ -3,5 +3,6 @@
|
||||
"members": "Members",
|
||||
"groups": "Groups",
|
||||
"teams": "Teams",
|
||||
"sso": "SSO",
|
||||
"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 |
@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
@ -85,6 +85,11 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const selectedRecipients = useWatch({
|
||||
control: form.control,
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
@ -151,7 +156,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full"
|
||||
className="h-5 w-5 rounded-full border border-neutral-400"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
@ -182,7 +187,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
<Button
|
||||
className="flex-1"
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={isSubmitting || selectedRecipients.length === 0}
|
||||
>
|
||||
<Trans>Send reminder</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
token,
|
||||
updatedAt,
|
||||
documentData,
|
||||
recipient,
|
||||
recipient: _recipient,
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
@ -95,6 +95,8 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||
|
||||
@ -345,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
useState(false);
|
||||
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
@ -118,6 +118,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
@ -307,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={
|
||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||
}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
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 { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -34,29 +31,33 @@ export type DocumentSigningFormProps = {
|
||||
document: DocumentAndSender;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
redirectUrl?: string | null;
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
completeDocument: (
|
||||
authOptions?: TRecipientActionAuth,
|
||||
nextSigner?: { email: string; name: string },
|
||||
) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
fieldsValidated: () => void;
|
||||
nextRecipient?: RecipientWithFields;
|
||||
};
|
||||
|
||||
export const DocumentSigningForm = ({
|
||||
document,
|
||||
recipient,
|
||||
fields,
|
||||
redirectUrl,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
setSelectedSignerId,
|
||||
completeDocument,
|
||||
isSubmitting,
|
||||
fieldsValidated,
|
||||
nextRecipient,
|
||||
}: DocumentSigningFormProps) => {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
@ -66,21 +67,12 @@ export const DocumentSigningForm = ({
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocumentWithToken,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||
defaultValues: {
|
||||
selectedSignerId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
const isSubmitting = isPending || isSuccess;
|
||||
|
||||
const fieldsRequiringValidation = useMemo(
|
||||
() => fields.filter(isFieldUnsignedAndRequired),
|
||||
[fields],
|
||||
@ -96,9 +88,9 @@ export const DocumentSigningForm = ({
|
||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||
}, [fieldsRequiringValidation, recipient]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
const localFieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
fieldsValidated();
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
@ -205,7 +148,7 @@ export const DocumentSigningForm = ({
|
||||
isSubmitting={isSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
fieldsValidated={localFieldsValidated}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
@ -364,7 +307,7 @@ export const DocumentSigningForm = ({
|
||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
fieldsValidated={localFieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (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 type { Field } from '@prisma/client';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
@ -18,8 +21,11 @@ import {
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -40,6 +46,7 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||
|
||||
export type DocumentSigningPageViewProps = {
|
||||
@ -63,9 +70,56 @@ export const DocumentSigningPageView = ({
|
||||
}: DocumentSigningPageViewProps) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocumentWithToken,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
const isSubmitting = isPending || isSuccess;
|
||||
|
||||
const fieldsRequiringValidation = useMemo(
|
||||
() => fields.filter(isFieldUnsignedAndRequired),
|
||||
[fields],
|
||||
);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
};
|
||||
|
||||
const completeDocument = async (
|
||||
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 senderEmail = `(${document.user.email})`;
|
||||
|
||||
@ -78,11 +132,42 @@ export const DocumentSigningPageView = ({
|
||||
const targetSigner =
|
||||
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||
// Sort by signingOrder first (nulls last), then by id
|
||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||
if (a.signingOrder === null) return 1;
|
||||
if (b.signingOrder === null) return -1;
|
||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||
return a.signingOrder - b.signingOrder;
|
||||
});
|
||||
|
||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||
? sortedRecipients[currentIndex + 1]
|
||||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
const hasPendingFields = pendingFields.length > 0;
|
||||
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||
{document.team.teamGlobalSettings.brandingEnabled &&
|
||||
document.team.teamGlobalSettings.brandingLogo && (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${document.teamId}`}
|
||||
alt={`${document.team.name}'s Logo`}
|
||||
className="mb-4 h-12 w-12 md:mb-2"
|
||||
/>
|
||||
)}
|
||||
<h1
|
||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
@ -165,19 +250,55 @@ export const DocumentSigningPageView = ({
|
||||
.otherwise(() => null)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
{match({ hasPendingFields, isExpanded, role: recipient.role })
|
||||
.with(
|
||||
{
|
||||
hasPendingFields: false,
|
||||
role: P.not(RecipientRole.ASSISTANT),
|
||||
isExpanded: false,
|
||||
},
|
||||
() => (
|
||||
<div className="md:hidden">
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
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)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
@ -206,10 +327,13 @@ export const DocumentSigningPageView = ({
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
completeDocument={completeDocument}
|
||||
isSubmitting={isSubmitting}
|
||||
fieldsValidated={fieldsValidated}
|
||||
nextRecipient={nextRecipient}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -159,34 +159,37 @@ export const DocumentEditForm = ({
|
||||
return initialStep;
|
||||
});
|
||||
|
||||
const saveSettingsData = async (data: TAddSettingsFormSchema) => {
|
||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
return updateDocument({
|
||||
documentId: document.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
timezone,
|
||||
dateFormat,
|
||||
redirectUrl,
|
||||
language: isValidLanguageCode(language) ? language : undefined,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||
try {
|
||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
await updateDocument({
|
||||
documentId: document.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
timezone,
|
||||
dateFormat,
|
||||
redirectUrl,
|
||||
language: isValidLanguageCode(language) ? language : undefined,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
},
|
||||
});
|
||||
|
||||
await saveSettingsData(data);
|
||||
setStep('signers');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -199,26 +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) => {
|
||||
try {
|
||||
await 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 ?? [],
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
await saveSignersData(data);
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
@ -232,12 +267,16 @@ export const DocumentEditForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
||||
return addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
await saveFieldsData(data);
|
||||
|
||||
// Clear all field data from localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
@ -259,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 } =
|
||||
data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailId,
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
emailSettings: emailSettings,
|
||||
},
|
||||
});
|
||||
return updateDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailId,
|
||||
emailReplyTo,
|
||||
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({
|
||||
title: _(msg`Document sent`),
|
||||
description: _(msg`Your document has been sent successfully.`),
|
||||
@ -304,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];
|
||||
|
||||
/**
|
||||
@ -349,25 +439,28 @@ export const DocumentEditForm = ({
|
||||
fields={fields}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
onAutoSave={onAddSettingsFormAutoSave}
|
||||
/>
|
||||
|
||||
<AddSignersFormPartial
|
||||
key={recipients.length}
|
||||
key={document.id}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
signingOrder={document.documentMeta?.signingOrder}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
fields={fields}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
onAutoSave={onAddSignersFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddFieldsFormPartial
|
||||
key={fields.length}
|
||||
key={document.id}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
onAutoSave={onAddFieldsFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
teamId={team.id}
|
||||
/>
|
||||
@ -379,6 +472,7 @@ export const DocumentEditForm = ({
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
onAutoSave={onAddSubjectFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
</Stepper>
|
||||
|
||||
@ -124,32 +124,36 @@ export const TemplateEditForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
const { signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
return updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
...data.meta,
|
||||
emailReplyTo: data.meta.emailReplyTo || null,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
try {
|
||||
await 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,
|
||||
},
|
||||
});
|
||||
await saveSettingsData(data);
|
||||
|
||||
setStep('signers');
|
||||
} 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 (
|
||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||
) => {
|
||||
try {
|
||||
await Promise.all([
|
||||
updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
meta: {
|
||||
signingOrder: data.signingOrder,
|
||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||
},
|
||||
}),
|
||||
|
||||
setRecipients({
|
||||
templateId: template.id,
|
||||
recipients: data.signers,
|
||||
}),
|
||||
]);
|
||||
await saveTemplatePlaceholderData(data);
|
||||
|
||||
setStep('fields');
|
||||
} 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) => {
|
||||
try {
|
||||
await addTemplateFields({
|
||||
templateId: template.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
await saveFieldsData(data);
|
||||
|
||||
// Clear all field data from localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
@ -270,11 +326,12 @@ export const TemplateEditForm = ({
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
onAutoSave={onAddSettingsFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplatePlaceholderRecipientsFormPartial
|
||||
key={recipients.length}
|
||||
key={template.id}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
@ -282,15 +339,17 @@ export const TemplateEditForm = ({
|
||||
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||
templateDirectLink={template.directLink}
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplateFieldsFormPartial
|
||||
key={fields.length}
|
||||
key={template.id}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
onAutoSave={onAddFieldsFormAutoSave}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
</Stepper>
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
GroupIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
Users2Icon,
|
||||
} from 'lucide-react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
@ -77,6 +78,11 @@ export default function SettingsLayout() {
|
||||
label: t`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/sso`,
|
||||
label: t`SSO`,
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/billing`,
|
||||
label: t`Billing`,
|
||||
@ -94,6 +100,13 @@ export default function SettingsLayout() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
|
||||
route.path.includes('/sso')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -192,6 +192,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -50,10 +50,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (document.folderId) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const recipients = await getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
@ -68,13 +64,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
||||
return {
|
||||
document,
|
||||
documentRootPath,
|
||||
recipients,
|
||||
documentRootPath,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { document, documentRootPath, recipients } = loaderData;
|
||||
const { document, recipients, documentRootPath } = loaderData;
|
||||
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
|
||||
@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Templates');
|
||||
|
||||
218
apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
Normal file
218
apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { MailsIcon } from 'lucide-react';
|
||||
import { Link, redirect, useSearchParams } from 'react-router';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.signin';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Sign In');
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={404}
|
||||
errorCodeMap={{
|
||||
404: {
|
||||
heading: msg`Authentication Portal Not Found`,
|
||||
subHeading: msg`404 Not Found`,
|
||||
message: msg`The organisation authentication portal does not exist, or is not configured`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Go back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { isAuthenticated, user } = await getOptionalSession(request);
|
||||
|
||||
const orgUrl = params.orgUrl;
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
url: orgUrl,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
organisationClaim: true,
|
||||
organisationAuthenticationPortal: {
|
||||
select: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!organisation ||
|
||||
!organisation.organisationAuthenticationPortal.enabled ||
|
||||
!organisation.organisationClaim.flags.authenticationPortal
|
||||
) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to organisation if already signed in and a member of the organisation.
|
||||
if (isAuthenticated && user && organisation.members.find((member) => member.userId === user.id)) {
|
||||
throw redirect(`/o/${orgUrl}`);
|
||||
}
|
||||
|
||||
return {
|
||||
organisationName: organisation.name,
|
||||
orgUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrganisationSignIn({ loaderData }: Route.ComponentProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { organisationName, orgUrl } = loaderData;
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
|
||||
|
||||
const action = searchParams.get('action');
|
||||
|
||||
const onSignInWithOIDCClick = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await authClient.oidc.org.signIn({
|
||||
orgUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
if (action === 'verification-required') {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<MailsIcon className="text-primary h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="">
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Confirmation email sent</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<Trans>
|
||||
To gain access to your account, please confirm your email address by clicking on the
|
||||
confirmation link from your inbox.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-x-2">
|
||||
<Button asChild>
|
||||
<Link to={`/o/${orgUrl}/signin`} replace>
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
<Trans>Welcome to {organisationName}</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Sign in to your account</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id={`flag-3rd-party-service`}
|
||||
checked={isConfirmationChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
htmlFor={`flag-3rd-party-service`}
|
||||
>
|
||||
<Trans>
|
||||
I understand that I am providing my credentials to a 3rd party service configured by
|
||||
this organisation
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background w-full"
|
||||
loading={isSubmitting}
|
||||
disabled={!isConfirmationChecked}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<Trans>Sign In</Trans>
|
||||
</Button>
|
||||
|
||||
<div className="relative mt-2 flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>OR</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-1 flex items-center justify-center text-xs">
|
||||
<Link to="/signin">
|
||||
<Trans>Return to Documenso sign in page here</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,333 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
|
||||
import { data, isRouteErrorResponse } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
|
||||
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatOrganisationLoginPath } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@documenso/ui/primitives/card';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { GenericErrorLayout, defaultErrorCodeMap } from '~/components/general/generic-error-layout';
|
||||
|
||||
import type { Route } from './+types/organisation.sso.confirmation.$token';
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const errorCode = isRouteErrorResponse(error) ? error.data.type : 500;
|
||||
|
||||
const errorMap = match(errorCode)
|
||||
.with('invalid-token', () => ({
|
||||
subHeading: msg`400 Error`,
|
||||
heading: msg`Invalid Token`,
|
||||
message: msg`The token is invalid or has expired.`,
|
||||
}))
|
||||
.otherwise(() => defaultErrorCodeMap[500]);
|
||||
|
||||
return (
|
||||
<GenericErrorLayout errorCode={500} errorCodeMap={{ 500: errorMap }} secondaryButton={null} />
|
||||
);
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
avatarImageId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = ZOrganisationAccountLinkMetadataSchema.safeParse(verificationToken.metadata);
|
||||
|
||||
if (!metadata.success) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
id: metadata.data.organisationId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
type: metadata.data.type,
|
||||
user: {
|
||||
name: verificationToken.user.name,
|
||||
email: verificationToken.user.email,
|
||||
avatar: verificationToken.user.avatarImageId,
|
||||
},
|
||||
organisation: {
|
||||
name: organisation.name,
|
||||
url: organisation.url,
|
||||
avatar: organisation.avatarImageId,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
|
||||
const { token, type, user, organisation } = loaderData;
|
||||
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
|
||||
|
||||
const { mutate: declineLinkOrganisationAccount, isPending: isDeclining } =
|
||||
trpc.enterprise.organisation.authenticationPortal.declineLinkAccount.useMutation({
|
||||
onSuccess: async () => {
|
||||
await navigate('/');
|
||||
|
||||
toast({
|
||||
title: 'Account link declined',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error declining account link',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: linkOrganisationAccount, isPending: isLinking } =
|
||||
trpc.enterprise.organisation.authenticationPortal.linkAccount.useMutation({
|
||||
onSuccess: async () => {
|
||||
await navigate(formatOrganisationLoginPath(organisation.url));
|
||||
|
||||
toast({
|
||||
title: 'Account linked successfully',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error linking account',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="w-full max-w-2xl border">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{type === 'link' ? (
|
||||
<Trans>Account Linking Request</Trans>
|
||||
) : (
|
||||
<Trans>Account Creation Request</Trans>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{type === 'link' ? (
|
||||
<Trans>
|
||||
An organisation wants to link your account. Please review the details below.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
An organisation wants to create an account for you. Please review the details below.
|
||||
</Trans>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Current User Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
|
||||
<UserCircle2 className="h-4 w-4" />
|
||||
<Trans>Your Account</Trans>
|
||||
</h3>
|
||||
<div className="bg-muted/50 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(user.avatar)}
|
||||
avatarFallback={extractInitials(user.name || user.email)}
|
||||
primaryText={user.name}
|
||||
secondaryText={user.email}
|
||||
/>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Trans>Account</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Organisation Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<Trans>Requesting Organisation</Trans>
|
||||
</h3>
|
||||
<div className="bg-muted/50 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(organisation.avatar)}
|
||||
avatarFallback={extractInitials(organisation.name)}
|
||||
primaryText={organisation.name}
|
||||
secondaryText={`/o/${organisation.url}`}
|
||||
/>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Trans>Organisation</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Warnings Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<Trans>Important: What This Means</Trans>
|
||||
</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>
|
||||
By accepting this request, you grant {organisation.name} the following
|
||||
permissions:
|
||||
</Trans>
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<Eye className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
Full account access:
|
||||
</span>{' '}
|
||||
View all your profile information, settings, and activity
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Settings className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
Account management:
|
||||
</span>{' '}
|
||||
Modify your account settings, permissions, and preferences
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Database className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="text-muted-foreground font-semibold">Data access:</span>{' '}
|
||||
Access all data associated with your account
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Alert variant="warning" className="mt-3">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
This organisation will have administrative control over your account. You can
|
||||
revoke this access later, but they will retain access to any data they've
|
||||
already collected.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id={`accept-conditions`}
|
||||
checked={isConfirmationChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
htmlFor={`accept-conditions`}
|
||||
>
|
||||
<Trans>I agree to link my account with this organization</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isDeclining || isLinking}
|
||||
onClick={() => declineLinkOrganisationAccount({ token })}
|
||||
>
|
||||
<Trans>Decline</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={!isConfirmationChecked || isDeclining || isLinking}
|
||||
loading={isLinking}
|
||||
onClick={() => linkOrganisationAccount({ token })}
|
||||
>
|
||||
<Trans>Accept & Link Account</Trans>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/remix/app/routes/api+/certificate-status.ts
Normal file
20
apps/remix/app/routes/api+/certificate-status.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
||||
|
||||
export const loader = () => {
|
||||
try {
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
return Response.json({
|
||||
isAvailable: certStatus.isAvailable,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
isAvailable: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -1,22 +1,49 @@
|
||||
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export async function loader() {
|
||||
type CheckStatus = 'ok' | 'warning' | 'error';
|
||||
|
||||
export const loader = async () => {
|
||||
const checks: {
|
||||
database: { status: CheckStatus };
|
||||
certificate: { status: CheckStatus };
|
||||
} = {
|
||||
database: { status: 'ok' },
|
||||
certificate: { status: 'ok' },
|
||||
};
|
||||
|
||||
let overallStatus: CheckStatus = 'ok';
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
message: 'All systems operational',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
} catch {
|
||||
checks.database = { status: 'error' };
|
||||
overallStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
if (certStatus.isAvailable) {
|
||||
checks.certificate = { status: 'ok' };
|
||||
} else {
|
||||
checks.certificate = { status: 'warning' };
|
||||
|
||||
if (overallStatus === 'ok') {
|
||||
overallStatus = 'warning';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
checks.certificate = { status: 'error' };
|
||||
overallStatus = 'error';
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
},
|
||||
{ status: overallStatus === 'error' ? 500 : 200 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -101,5 +101,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.12.2-rc.6"
|
||||
"version": "1.12.4"
|
||||
}
|
||||
|
||||
BIN
apps/remix/public/static/building-2.png
Normal file
BIN
apps/remix/public/static/building-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 B |
@ -109,7 +109,7 @@ COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./pa
|
||||
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
|
||||
|
||||
|
||||
# Get the start script from docker/start.sh
|
||||
# Get the start script from docker/
|
||||
COPY --chown=nodejs:nodejs ./docker/start.sh /app/apps/remix/start.sh
|
||||
|
||||
WORKDIR /app/apps/remix
|
||||
|
||||
149
docker/README.md
149
docker/README.md
@ -18,27 +18,66 @@ This setup includes a PostgreSQL database and the Documenso application. You wil
|
||||
3. Create a `.env` file in the same directory and add your SMTP details as well as a few extra environment variables, following the example below:
|
||||
|
||||
```
|
||||
# Generate random secrets (you can use: openssl rand -hex 32)
|
||||
NEXTAUTH_SECRET="<your-secret>"
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY="<your-key>"
|
||||
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-secondary-key>"
|
||||
|
||||
# Your application URL
|
||||
NEXT_PUBLIC_WEBAPP_URL="<your-url>"
|
||||
|
||||
# SMTP Configuration
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||
NEXT_PRIVATE_SMTP_HOST="<your-host>"
|
||||
NEXT_PRIVATE_SMTP_PORT=<your-port>
|
||||
NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME="<your-from-name>"
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-from-email>"
|
||||
|
||||
# Certificate passphrase (required)
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
|
||||
```
|
||||
|
||||
4. Update the volume binding for the cert file in the `compose.yml` file to point to your own key file:
|
||||
4. Set up your signing certificate. You have three options:
|
||||
|
||||
Since the `cert.p12` file is required for signing and encrypting documents, you will need to provide your own key file. Update the volume binding in the `compose.yml` file to point to your key file:
|
||||
**Option A: Generate Certificate Inside Container (Recommended)**
|
||||
|
||||
Start your containers first, then generate a self-signed certificate:
|
||||
```bash
|
||||
# Start containers
|
||||
docker-compose up -d
|
||||
|
||||
# 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
|
||||
"
|
||||
|
||||
# Restart container
|
||||
docker-compose restart documenso
|
||||
```
|
||||
|
||||
**Option B: Use Existing Certificate**
|
||||
|
||||
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
|
||||
```
|
||||
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
|
||||
```
|
||||
|
||||
1. Run the following command to start the containers:
|
||||
5. Run the following command to start the containers:
|
||||
|
||||
```
|
||||
docker-compose --env-file ./.env up -d
|
||||
@ -46,7 +85,7 @@ docker-compose --env-file ./.env up -d
|
||||
|
||||
This will start the PostgreSQL database and the Documenso application containers.
|
||||
|
||||
5. Access the Documenso application by visiting `http://localhost:3000` in your web browser.
|
||||
6. Access the Documenso application by visiting `http://localhost:3000` in your web browser.
|
||||
|
||||
## Option 2: Standalone Docker Container
|
||||
|
||||
@ -69,27 +108,93 @@ docker pull ghcr.io/documenso/documenso
|
||||
```
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
|
||||
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
|
||||
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
|
||||
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>"
|
||||
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>"
|
||||
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>"
|
||||
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>"
|
||||
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>"
|
||||
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>"
|
||||
-v /path/to/your/keyfile.p12:/opt/documenso/cert.p12
|
||||
-e NEXTAUTH_SECRET="<your-nextauth-secret>" \
|
||||
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>" \
|
||||
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>" \
|
||||
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>" \
|
||||
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
|
||||
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>" \
|
||||
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>" \
|
||||
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>" \
|
||||
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>" \
|
||||
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>" \
|
||||
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>" \
|
||||
-v /path/to/your/cert.p12:/opt/documenso/cert.p12:ro \
|
||||
documenso/documenso
|
||||
```
|
||||
|
||||
Replace the placeholders with your actual database and SMTP details.
|
||||
|
||||
1. Access the Documenso application by visiting the URL you provided in the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
|
||||
3. Access the Documenso application by visiting the URL you provided in the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
|
||||
|
||||
## Success
|
||||
|
||||
You have now successfully set up Documenso using Docker. You can start organizing and managing your documents efficiently. If you encounter any issues or have further questions, please refer to the official Documenso documentation or seek assistance from the community.
|
||||
You have now successfully set up Documenso using Docker. You can start organizing and managing your documents efficiently.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate Permission Issues
|
||||
|
||||
If you encounter errors related to certificate access, here are common solutions:
|
||||
|
||||
#### Error: "Failed to read signing certificate"
|
||||
|
||||
1. **Check file exists:**
|
||||
|
||||
```bash
|
||||
ls -la /path/to/your/cert.p12
|
||||
```
|
||||
|
||||
2. **Fix permissions:**
|
||||
|
||||
```bash
|
||||
chmod 644 /path/to/your/cert.p12
|
||||
chown 1001:1001 /path/to/your/cert.p12
|
||||
```
|
||||
|
||||
3. **Verify Docker mount:**
|
||||
```bash
|
||||
docker exec -it <container_name> ls -la /opt/documenso/cert.p12
|
||||
```
|
||||
|
||||
|
||||
### Container Logs
|
||||
|
||||
Check application logs for detailed error information:
|
||||
|
||||
```bash
|
||||
# For Docker Compose
|
||||
docker-compose logs -f documenso
|
||||
|
||||
# For standalone container
|
||||
docker logs -f <container_name>
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
Check the status of your Documenso instance:
|
||||
|
||||
```bash
|
||||
# Basic health check (database + certificate)
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Detailed certificate status
|
||||
curl http://localhost:3000/api/certificate-status
|
||||
```
|
||||
|
||||
The health endpoint will show:
|
||||
|
||||
- `status: "ok"` - Everything working properly
|
||||
- `status: "warning"` - App running but certificate issues
|
||||
- `status: "error"` - Critical issues (database down, etc.)
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port already in use:** Change the port mapping in compose.yml or your docker run command
|
||||
2. **Database connection issues:** Ensure your database is running and accessible
|
||||
3. **SMTP errors:** Verify your email server settings in the .env file
|
||||
|
||||
If you encounter any issues or have further questions, please refer to the official Documenso documentation or seek assistance from the community.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
|
||||
@ -46,6 +46,7 @@ services:
|
||||
- NEXT_PRIVATE_SMTP_APIKEY_USER=${NEXT_PRIVATE_SMTP_APIKEY_USER}
|
||||
- NEXT_PRIVATE_SMTP_APIKEY=${NEXT_PRIVATE_SMTP_APIKEY}
|
||||
- NEXT_PRIVATE_SMTP_SECURE=${NEXT_PRIVATE_SMTP_SECURE}
|
||||
- NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=${NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS}
|
||||
- NEXT_PRIVATE_SMTP_FROM_NAME=${NEXT_PRIVATE_SMTP_FROM_NAME:?err}
|
||||
- NEXT_PRIVATE_SMTP_FROM_ADDRESS=${NEXT_PRIVATE_SMTP_FROM_ADDRESS:?err}
|
||||
- NEXT_PRIVATE_SMTP_SERVICE=${NEXT_PRIVATE_SMTP_SERVICE}
|
||||
@ -63,7 +64,7 @@ services:
|
||||
ports:
|
||||
- ${PORT:-3000}:${PORT:-3000}
|
||||
volumes:
|
||||
- /opt/documenso/cert.p12:/opt/documenso/cert.p12
|
||||
- /opt/documenso/cert.p12:/opt/documenso/cert.p12:ro
|
||||
|
||||
volumes:
|
||||
database:
|
||||
|
||||
@ -1,7 +1,31 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -x
|
||||
# 🚀 Starting Documenso...
|
||||
printf "🚀 Starting Documenso...\n\n"
|
||||
|
||||
# 🔐 Check certificate configuration
|
||||
printf "🔐 Checking certificate configuration...\n"
|
||||
|
||||
CERT_PATH="${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}"
|
||||
|
||||
if [ -f "$CERT_PATH" ] && [ -r "$CERT_PATH" ]; then
|
||||
printf "✅ Certificate file found and readable - document signing is ready!\n"
|
||||
else
|
||||
printf "⚠️ Certificate not found or not readable\n"
|
||||
printf "💡 Tip: Documenso will still start, but document signing will be unavailable\n"
|
||||
printf "🔧 Check: http://localhost:3000/api/certificate-status for detailed status\n"
|
||||
fi
|
||||
|
||||
printf "\n📚 Useful Links:\n"
|
||||
printf "📖 Documentation: https://docs.documenso.com\n"
|
||||
printf "🐳 Self-hosting guide: https://docs.documenso.com/developers/self-hosting\n"
|
||||
printf "🔐 Certificate setup: https://docs.documenso.com/developers/self-hosting/signing-certificate\n"
|
||||
printf "🏥 Health check: http://localhost:3000/api/health\n"
|
||||
printf "📊 Certificate status: http://localhost:3000/api/certificate-status\n"
|
||||
printf "👥 Community: https://github.com/documenso/documenso\n\n"
|
||||
|
||||
printf "🗄️ Running database migrations...\n"
|
||||
npx prisma migrate deploy --schema ../../packages/prisma/schema.prisma
|
||||
|
||||
printf "🌟 Starting Documenso server...\n"
|
||||
HOSTNAME=0.0.0.0 node build/server/main.js
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.2-rc.6",
|
||||
"version": "1.12.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.2-rc.6",
|
||||
"version": "1.12.4",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -89,7 +89,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.12.2-rc.6",
|
||||
"version": "1.12.4",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.12.2-rc.6",
|
||||
"version": "1.12.4",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@ -34,6 +34,7 @@ import { createTemplate } from '@documenso/lib/server-only/template/create-templ
|
||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
@ -980,10 +981,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
recipients: [
|
||||
...recipients.map(({ email, name }) => ({
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
...recipients.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? [],
|
||||
})),
|
||||
{
|
||||
email,
|
||||
|
||||
@ -33,7 +33,7 @@ export const ZNoBodyMutationSchema = null;
|
||||
*/
|
||||
export const ZGetDocumentsQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).optional().default(1),
|
||||
perPage: z.coerce.number().min(1).optional().default(1),
|
||||
perPage: z.coerce.number().min(1).optional().default(10),
|
||||
});
|
||||
|
||||
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
|
||||
@ -637,5 +637,5 @@ export const ZSuccessfulGetTemplatesResponseSchema = z.object({
|
||||
|
||||
export const ZGetTemplatesQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).optional().default(1),
|
||||
perPage: z.coerce.number().min(1).optional().default(1),
|
||||
perPage: z.coerce.number().min(1).optional().default(10),
|
||||
});
|
||||
|
||||
@ -0,0 +1,293 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add signer' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
return { user, team, document };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Fields Step', () => {
|
||||
test('should autosave the fields without advanced settings', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(3);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
expect(retrievedFields[2].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the field deletion', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(2);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the field duplication', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Signature').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(4);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
expect(retrievedFields[2].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[3].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the fields with advanced settings', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
|
||||
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
|
||||
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(2);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
|
||||
const textField = retrievedFields[1];
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
textField.fieldMeta &&
|
||||
typeof textField.fieldMeta === 'object' &&
|
||||
'type' in textField.fieldMeta
|
||||
) {
|
||||
expect(textField.fieldMeta.type).toBe('text');
|
||||
expect(textField.fieldMeta.label).toBe('Test Field');
|
||||
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
|
||||
|
||||
if (textField.fieldMeta.type === 'text') {
|
||||
expect(textField.fieldMeta.text).toBe('Test Text');
|
||||
}
|
||||
} else {
|
||||
throw new Error('fieldMeta should be defined and contain advanced settings');
|
||||
}
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,243 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const setupDocument = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, document };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Settings Step', () => {
|
||||
test('should autosave the title change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newDocumentTitle = 'New Document Title';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill(newDocumentTitle);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrieved.title);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the language change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newDocumentLanguage = 'French';
|
||||
const expectedLanguageCode = 'fr';
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: newDocumentLanguage }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.language).toBe(expectedLanguageCode);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the document access change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const access = 'Require account';
|
||||
const accessValue = 'ACCOUNT';
|
||||
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: access }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.authOptions?.globalAccessAuth).toContain(accessValue);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the external ID change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newExternalId = '1234567890';
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.externalId).toBe(newExternalId);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the allowed signature types change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
await page.getByRole('option', { name: 'Type' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
|
||||
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
|
||||
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the date format change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the timezone change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('option', { name: 'Europe/London' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.timezone).toBe('Europe/London');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the redirect URL change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newRedirectUrl = 'https://documenso.com/test/';
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.redirectUrl).toBe(newRedirectUrl);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave multiple field changes together', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newTitle = 'Updated Document Title';
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: 'Require account' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
const newExternalId = 'MULTI-TEST-123';
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.title).toBe(newTitle);
|
||||
expect(retrieved.documentMeta?.language).toBe('de');
|
||||
expect(retrieved.authOptions?.globalAccessAuth).toContain('ACCOUNT');
|
||||
expect(retrieved.externalId).toBe(newExternalId);
|
||||
expect(retrieved.documentMeta?.timezone).toBe('Europe/Berlin');
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,168 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
return { user, team, document };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
const addSignerAndSave = async (page: Page) => {
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await triggerAutosave(page);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Signers Step', () => {
|
||||
test('should autosave the signers addition', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
|
||||
expect(retrievedRecipients[0].name).toBe('Recipient 1');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signer deletion', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add myself' }).click();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('remove-signer-button').first().click();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe(user.email);
|
||||
expect(retrievedRecipients[0].name).toBe(user.name);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signer update', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByPlaceholder('Name').fill('Documenso Manager');
|
||||
await page.getByPlaceholder('Email').fill('manager@documenso.com');
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Receives copy' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
|
||||
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
|
||||
expect(retrievedRecipients[0].role).toBe('CC');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signing order change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add signer' }).click();
|
||||
|
||||
await page.getByTestId('signer-email-input').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByTestId('signer-email-input').nth(2).fill('recipient3@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Recipient 3');
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByLabel('Enable signing order').check();
|
||||
await page.getByLabel('Allow signers to dictate next signer').check();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('signing-order-input').nth(0).fill('3');
|
||||
await page.getByTestId('signing-order-input').nth(0).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('signing-order-input').nth(1).fill('1');
|
||||
await page.getByTestId('signing-order-input').nth(1).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('signing-order-input').nth(2).fill('2');
|
||||
await page.getByTestId('signing-order-input').nth(2).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
|
||||
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
|
||||
expect(retrievedRecipients.length).toBe(3);
|
||||
expect(retrievedRecipients[0].signingOrder).toBe(2);
|
||||
expect(retrievedRecipients[1].signingOrder).toBe(3);
|
||||
expect(retrievedRecipients[2].signingOrder).toBe(1);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,200 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
return { user, team, document };
|
||||
};
|
||||
|
||||
export const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Subject Step', () => {
|
||||
test('should autosave the subject field', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
const subject = 'Hello world!';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
|
||||
retrievedDocumentData.documentMeta?.subject ?? '',
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the message field', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
const message = 'Please review and sign this important document. Thank you!';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
|
||||
retrievedDocumentData.documentMeta?.message ?? '',
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the email settings checkboxes', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
|
||||
await page.getByText('Send recipient signed email').click();
|
||||
await page.getByText('Send recipient removed email').click();
|
||||
await page.getByText('Send document completed email', { exact: true }).click();
|
||||
await page.getByText('Send document deleted email').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
|
||||
|
||||
await expect(page.getByText('Send recipient signed email')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(page.getByText('Send recipient removed email')).toBeChecked({
|
||||
checked: emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
|
||||
checked: emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Send document deleted email')).toBeChecked({
|
||||
checked: emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Send document pending email')).toBeChecked({
|
||||
checked: emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
|
||||
checked: emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave all fields and settings together', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
const subject = 'Combined Test Subject - Please Sign';
|
||||
const message =
|
||||
'This is a comprehensive test message for autosave functionality. Please review and sign at your earliest convenience.';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
|
||||
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
|
||||
|
||||
await page.getByText('Send recipient signed email').click();
|
||||
await page.getByText('Send recipient removed email').click();
|
||||
await page.getByText('Send document completed email', { exact: true }).click();
|
||||
await page.getByText('Send document deleted email').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedDocumentData.documentMeta?.subject).toBe(subject);
|
||||
expect(retrievedDocumentData.documentMeta?.message).toBe(message);
|
||||
expect(retrievedDocumentData.documentMeta?.emailSettings).toBeDefined();
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
|
||||
retrievedDocumentData.documentMeta?.subject ?? '',
|
||||
);
|
||||
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
|
||||
retrievedDocumentData.documentMeta?.message ?? '',
|
||||
);
|
||||
|
||||
await expect(page.getByText('Send recipient signed email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(page.getByText('Send recipient removed email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Send document deleted email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Send document pending email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -534,9 +534,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
await page.getByLabel('Title').fill(documentTitle);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
await page.getByLabel('Enable signing order').check();
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
if (i > 1) {
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
@ -558,6 +555,9 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
.fill(`User ${i}`);
|
||||
}
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
await page.getByLabel('Enable signing order').check();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
@ -0,0 +1,304 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Fields Step', () => {
|
||||
test('should autosave the fields without advanced settings', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(3);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields[2].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the field deletion', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(2);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the field duplication', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Signature').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(4);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields[2].type).toBe('SIGNATURE');
|
||||
expect(fields[3].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the fields with advanced settings', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
|
||||
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
|
||||
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const fields = retrievedTemplate.fields;
|
||||
|
||||
expect(fields.length).toBe(2);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
|
||||
const textField = fields[1];
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
textField.fieldMeta &&
|
||||
typeof textField.fieldMeta === 'object' &&
|
||||
'type' in textField.fieldMeta
|
||||
) {
|
||||
expect(textField.fieldMeta.type).toBe('text');
|
||||
expect(textField.fieldMeta.label).toBe('Test Field');
|
||||
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
|
||||
|
||||
if (textField.fieldMeta.type === 'text') {
|
||||
expect(textField.fieldMeta.text).toBe('Test Text');
|
||||
}
|
||||
} else {
|
||||
throw new Error('fieldMeta should be defined and contain advanced settings');
|
||||
}
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,244 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const setupTemplate = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Settings Step - Templates', () => {
|
||||
test('should autosave the title change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newTemplateTitle = 'New Template Title';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill(newTemplateTitle);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(
|
||||
retrievedTemplate.title,
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the language change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newTemplateLanguage = 'French';
|
||||
const expectedLanguageCode = 'fr';
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: newTemplateLanguage }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.language).toBe(expectedLanguageCode);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the template access change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const access = 'Require account';
|
||||
const accessValue = 'ACCOUNT';
|
||||
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: access }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain(accessValue);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the external ID change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newExternalId = '1234567890';
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.externalId).toBe(newExternalId);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the allowed signature types change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
await page.getByRole('option', { name: 'Type' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
|
||||
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
|
||||
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the date format change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the timezone change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('option', { name: 'Europe/London' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/London');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the redirect URL change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newRedirectUrl = 'https://documenso.com/test/';
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.redirectUrl).toBe(newRedirectUrl);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave multiple field changes together', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newTitle = 'Updated Template Title';
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: 'Require account' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
const newExternalId = 'MULTI-TEST-123';
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.title).toBe(newTitle);
|
||||
expect(retrievedTemplate.templateMeta?.language).toBe('de');
|
||||
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain('ACCOUNT');
|
||||
expect(retrievedTemplate.externalId).toBe(newExternalId);
|
||||
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/Berlin');
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,174 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
const addSignerAndSave = async (page: Page) => {
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await triggerAutosave(page);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Signers Step - Templates', () => {
|
||||
test('should autosave the signers addition', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
|
||||
expect(retrievedRecipients[0].name).toBe('Recipient 1');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signer deletion', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add myself' }).click();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('remove-placeholder-recipient-button').first().click();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe(user.email);
|
||||
expect(retrievedRecipients[0].name).toBe(user.name);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signer update', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByPlaceholder('Name').fill('Documenso Manager');
|
||||
await page.getByPlaceholder('Email').fill('manager@documenso.com');
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Receives copy' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
|
||||
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
|
||||
expect(retrievedRecipients[0].role).toBe('CC');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signing order change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
|
||||
|
||||
await page
|
||||
.getByTestId('placeholder-recipient-email-input')
|
||||
.nth(1)
|
||||
.fill('recipient2@documenso.com');
|
||||
await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
|
||||
|
||||
await page
|
||||
.getByTestId('placeholder-recipient-email-input')
|
||||
.nth(2)
|
||||
.fill('recipient3@documenso.com');
|
||||
await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3');
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByLabel('Enable signing order').check();
|
||||
await page.getByLabel('Allow signers to dictate next signer').check();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).fill('3');
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).fill('1');
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).fill('2');
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.signingOrder).toBe('SEQUENTIAL');
|
||||
expect(retrievedTemplate.templateMeta?.allowDictateNextSigner).toBe(true);
|
||||
expect(retrievedRecipients.length).toBe(3);
|
||||
expect(retrievedRecipients[0].signingOrder).toBe(2);
|
||||
expect(retrievedRecipients[1].signingOrder).toBe(3);
|
||||
expect(retrievedRecipients[2].signingOrder).toBe(1);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -17,7 +17,7 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
workers: 4,
|
||||
maxFailures: process.env.CI ? 1 : undefined,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
@ -7,6 +7,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import type { AuthAppType } from '../server';
|
||||
import type { SessionValidationResult } from '../server/lib/session/session';
|
||||
import type { PartialAccount } from '../server/lib/utils/get-accounts';
|
||||
import type { ActiveSession } from '../server/lib/utils/get-session';
|
||||
import { handleSignInRedirect } from '../server/lib/utils/redirect';
|
||||
import type {
|
||||
@ -96,6 +97,25 @@ export class AuthClient {
|
||||
}
|
||||
}
|
||||
|
||||
public account = {
|
||||
getMany: async () => {
|
||||
const response = await this.client['accounts'].$get();
|
||||
|
||||
await this.handleError(response);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return superjson.deserialize<{ accounts: PartialAccount[] }>(result);
|
||||
},
|
||||
delete: async (accountId: string) => {
|
||||
const response = await this.client['account'][':accountId'].$delete({
|
||||
param: { accountId },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
},
|
||||
};
|
||||
|
||||
public emailPassword = {
|
||||
signIn: async (data: Omit<TEmailPasswordSignin, 'csrfToken'> & { csrfToken?: string }) => {
|
||||
let csrfToken = data.csrfToken;
|
||||
@ -214,6 +234,22 @@ export class AuthClient {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
org: {
|
||||
signIn: async ({ orgUrl }: { orgUrl: string }) => {
|
||||
const response = await this.client['oauth'].authorize.oidc.org[':orgUrl'].$post({
|
||||
param: { orgUrl },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to external OIDC provider URL.
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
|
||||
import { setCsrfCookie } from './lib/session/session-cookies';
|
||||
import { accountRoute } from './routes/account';
|
||||
import { callbackRoute } from './routes/callback';
|
||||
import { emailPasswordRoute } from './routes/email-password';
|
||||
import { oauthRoute } from './routes/oauth';
|
||||
@ -43,6 +44,7 @@ export const auth = new Hono<HonoAuthContext>()
|
||||
})
|
||||
.route('/', sessionRoute)
|
||||
.route('/', signOutRoute)
|
||||
.route('/', accountRoute)
|
||||
.route('/callback', callbackRoute)
|
||||
.route('/oauth', oauthRoute)
|
||||
.route('/email-password', emailPasswordRoute)
|
||||
|
||||
37
packages/auth/server/lib/utils/delete-account-provider.ts
Normal file
37
packages/auth/server/lib/utils/delete-account-provider.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getSession } from './get-session';
|
||||
|
||||
export const deleteAccountProvider = async (c: Context, accountId: string): Promise<void> => {
|
||||
const { user } = await getSession(c);
|
||||
|
||||
const requestMeta = c.get('requestMetadata');
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const deletedAccountProvider = await tx.account.delete({
|
||||
where: {
|
||||
id: accountId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type:
|
||||
deletedAccountProvider.type === ORGANISATION_USER_ACCOUNT_TYPE
|
||||
? UserSecurityAuditLogType.ORGANISATION_SSO_UNLINK
|
||||
: UserSecurityAuditLogType.ACCOUNT_SSO_UNLINK,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
32
packages/auth/server/lib/utils/get-accounts.ts
Normal file
32
packages/auth/server/lib/utils/get-accounts.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getSession } from './get-session';
|
||||
|
||||
export type PartialAccount = {
|
||||
id: string;
|
||||
userId: number;
|
||||
type: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export const getAccounts = async (c: Context | Request): Promise<PartialAccount[]> => {
|
||||
const { user } = await getSession(c);
|
||||
|
||||
return await prisma.account.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
type: true,
|
||||
provider: true,
|
||||
providerAccountId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -20,70 +20,10 @@ type HandleOAuthCallbackUrlOptions = {
|
||||
export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
const { c, clientOptions } = options;
|
||||
|
||||
if (!clientOptions.clientId || !clientOptions.clientSecret) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||
}
|
||||
|
||||
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
|
||||
requiredScopes: clientOptions.scope,
|
||||
});
|
||||
|
||||
const oAuthClient = new OAuth2Client(
|
||||
clientOptions.clientId,
|
||||
clientOptions.clientSecret,
|
||||
clientOptions.redirectUrl,
|
||||
);
|
||||
|
||||
const requestMeta = c.get('requestMetadata');
|
||||
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
|
||||
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
|
||||
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
|
||||
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
|
||||
|
||||
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid or missing state',
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
||||
|
||||
if (redirectState !== storedState || !redirectPath) {
|
||||
redirectPath = '/';
|
||||
}
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(
|
||||
token_endpoint,
|
||||
code,
|
||||
storedCodeVerifier,
|
||||
);
|
||||
|
||||
const accessToken = tokens.accessToken();
|
||||
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
const idToken = tokens.idToken();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
|
||||
|
||||
const email = claims.email;
|
||||
const name = claims.name;
|
||||
const sub = claims.sub;
|
||||
|
||||
if (typeof email !== 'string' || typeof name !== 'string' || typeof sub !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Invalid claims',
|
||||
});
|
||||
}
|
||||
|
||||
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
|
||||
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
|
||||
message: 'Account email is not verified',
|
||||
});
|
||||
}
|
||||
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath } =
|
||||
await validateOauth({ c, clientOptions });
|
||||
|
||||
// Find the account if possible.
|
||||
const existingAccount = await prisma.account.findFirst({
|
||||
@ -199,3 +139,92 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
};
|
||||
|
||||
export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
const { c, clientOptions } = options;
|
||||
|
||||
if (!clientOptions.clientId || !clientOptions.clientSecret) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||
}
|
||||
|
||||
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
|
||||
requiredScopes: clientOptions.scope,
|
||||
});
|
||||
|
||||
const oAuthClient = new OAuth2Client(
|
||||
clientOptions.clientId,
|
||||
clientOptions.clientSecret,
|
||||
clientOptions.redirectUrl,
|
||||
);
|
||||
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
|
||||
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
|
||||
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
|
||||
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
|
||||
|
||||
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid or missing state',
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
||||
|
||||
if (redirectState !== storedState || !redirectPath) {
|
||||
redirectPath = '/';
|
||||
}
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(
|
||||
token_endpoint,
|
||||
code,
|
||||
storedCodeVerifier,
|
||||
);
|
||||
|
||||
const accessToken = tokens.accessToken();
|
||||
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
const idToken = tokens.idToken();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
|
||||
|
||||
const email = claims.email;
|
||||
const name = claims.name;
|
||||
const sub = claims.sub;
|
||||
|
||||
if (typeof email !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing email',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof name !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing name',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof sub !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing sub claim',
|
||||
});
|
||||
}
|
||||
|
||||
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
|
||||
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
|
||||
message: 'Account email is not verified',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
sub,
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
idToken,
|
||||
redirectPath,
|
||||
};
|
||||
};
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||
import { onAuthorize } from './authorizer';
|
||||
import { validateOauth } from './handle-oauth-callback-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from './organisation-portal';
|
||||
|
||||
type HandleOAuthOrganisationCallbackUrlOptions = {
|
||||
c: Context;
|
||||
orgUrl: string;
|
||||
};
|
||||
|
||||
export const handleOAuthOrganisationCallbackUrl = async (
|
||||
options: HandleOAuthOrganisationCallbackUrlOptions,
|
||||
) => {
|
||||
const { c, orgUrl } = options;
|
||||
|
||||
const { organisation, clientOptions } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'url',
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
|
||||
c,
|
||||
clientOptions: {
|
||||
...clientOptions,
|
||||
bypassEmailVerification: true, // Bypass for organisation OIDC because we manually verify the email.
|
||||
},
|
||||
});
|
||||
|
||||
const allowedDomains = organisation.organisationAuthenticationPortal.allowedDomains;
|
||||
|
||||
if (allowedDomains.length > 0 && !allowedDomains.some((domain) => email.endsWith(`@${domain}`))) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Email domain not allowed',
|
||||
});
|
||||
}
|
||||
|
||||
// Find the account if possible.
|
||||
const existingAccount = await prisma.account.findFirst({
|
||||
where: {
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: sub,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Directly log in user if account already exists.
|
||||
if (existingAccount) {
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(`/o/${orgUrl}`, 302);
|
||||
}
|
||||
|
||||
let userToLink = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle new user.
|
||||
if (!userToLink) {
|
||||
userToLink = await prisma.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
name: name,
|
||||
emailVerified: null, // Do not verify email.
|
||||
},
|
||||
});
|
||||
|
||||
await onCreateUserHook(userToLink).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
await sendOrganisationAccountLinkConfirmationEmail({
|
||||
type: userToLink.emailVerified ? 'link' : 'create',
|
||||
userId: userToLink.id,
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
oauthConfig: {
|
||||
accessToken,
|
||||
idToken,
|
||||
providerAccountId: sub,
|
||||
expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect(`${formatOrganisationLoginUrl(orgUrl)}?action=verification-required`, 302);
|
||||
};
|
||||
94
packages/auth/server/lib/utils/organisation-portal.ts
Normal file
94
packages/auth/server/lib/utils/organisation-portal.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatOrganisationCallbackUrl } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
type GetOrganisationAuthenticationPortalOptions =
|
||||
| {
|
||||
type: 'url';
|
||||
organisationUrl: string;
|
||||
}
|
||||
| {
|
||||
type: 'id';
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const getOrganisationAuthenticationPortalOptions = async (
|
||||
options: GetOrganisationAuthenticationPortalOptions,
|
||||
) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where:
|
||||
options.type === 'url'
|
||||
? {
|
||||
url: options.organisationUrl,
|
||||
}
|
||||
: {
|
||||
id: options.organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
organisationAuthenticationPortal: true,
|
||||
groups: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Billing is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!organisation.organisationClaim.flags.authenticationPortal ||
|
||||
!organisation.organisationAuthenticationPortal.enabled
|
||||
) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Authentication portal is not enabled for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
clientId,
|
||||
clientSecret: encryptedClientSecret,
|
||||
wellKnownUrl,
|
||||
} = organisation.organisationAuthenticationPortal;
|
||||
|
||||
if (!clientId || !encryptedClientSecret || !wellKnownUrl) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Authentication portal is not configured for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
if (!DOCUMENSO_ENCRYPTION_KEY) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Encryption key is not set',
|
||||
});
|
||||
}
|
||||
|
||||
const clientSecret = Buffer.from(
|
||||
symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_KEY, data: encryptedClientSecret }),
|
||||
).toString('utf-8');
|
||||
|
||||
return {
|
||||
organisation,
|
||||
clientId,
|
||||
clientSecret,
|
||||
wellKnownUrl,
|
||||
clientOptions: {
|
||||
id: organisation.id,
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUrl: formatOrganisationCallbackUrl(organisation.url),
|
||||
wellKnownUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
25
packages/auth/server/routes/account.ts
Normal file
25
packages/auth/server/routes/account.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Hono } from 'hono';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { deleteAccountProvider } from '../lib/utils/delete-account-provider';
|
||||
import { getAccounts } from '../lib/utils/get-accounts';
|
||||
|
||||
export const accountRoute = new Hono()
|
||||
/**
|
||||
* Get all linked accounts.
|
||||
*/
|
||||
.get('/accounts', async (c) => {
|
||||
const accounts = await getAccounts(c);
|
||||
|
||||
return c.json(superjson.serialize({ accounts }));
|
||||
})
|
||||
/**
|
||||
* Delete an account linking method.
|
||||
*/
|
||||
.delete('/account/:accountId', async (c) => {
|
||||
const accountId = c.req.param('accountId');
|
||||
|
||||
await deleteAccountProvider(c, accountId);
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
@ -1,7 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
|
||||
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
|
||||
/**
|
||||
@ -14,6 +17,31 @@ export const callbackRoute = new Hono<HonoAuthContext>()
|
||||
*/
|
||||
.get('/oidc', async (c) => handleOAuthCallbackUrl({ c, clientOptions: OidcAuthOptions }))
|
||||
|
||||
/**
|
||||
* Organisation OIDC callback verification.
|
||||
*/
|
||||
.get('/oidc/org/:orgUrl', async (c) => {
|
||||
const orgUrl = c.req.param('orgUrl');
|
||||
|
||||
try {
|
||||
return await handleOAuthOrganisationCallbackUrl({
|
||||
c,
|
||||
orgUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
throw new AppError(err.name, {
|
||||
message: err.message,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Google callback verification.
|
||||
*/
|
||||
|
||||
@ -16,7 +16,7 @@ import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-only/user/get-most-recent-verification-token-by-user-id';
|
||||
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
|
||||
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||
@ -105,7 +105,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
|
||||
const mostRecentToken = await getMostRecentEmailVerificationToken({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
|
||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
|
||||
const ZOAuthAuthorizeSchema = z.object({
|
||||
@ -34,4 +35,20 @@ export const oauthRoute = new Hono<HonoAuthContext>()
|
||||
clientOptions: OidcAuthOptions,
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
/**
|
||||
* Organisation OIDC authorize endpoint.
|
||||
*/
|
||||
.post('/authorize/oidc/org/:orgUrl', async (c) => {
|
||||
const orgUrl = c.req.param('orgUrl');
|
||||
|
||||
const { clientOptions } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'url',
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
return await handleOAuthAuthorizeUrl({
|
||||
c,
|
||||
clientOptions,
|
||||
});
|
||||
});
|
||||
|
||||
163
packages/ee/server-only/lib/link-organisation-account.ts
Normal file
163
packages/ee/server-only/lib/link-organisation-account.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { getOrganisationAuthenticationPortalOptions } from '@documenso/auth/server/lib/utils/organisation-portal';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface LinkOrganisationAccountOptions {
|
||||
token: string;
|
||||
requestMeta: RequestMetadata;
|
||||
}
|
||||
|
||||
export const linkOrganisationAccount = async ({
|
||||
token,
|
||||
requestMeta,
|
||||
}: LinkOrganisationAccountOptions) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Billing is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the token since it contains unnecessary sensitive data.
|
||||
const verificationToken = await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
emailVerified: true,
|
||||
accounts: {
|
||||
select: {
|
||||
provider: true,
|
||||
providerAccountId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationToken.completed) {
|
||||
throw new AppError('ALREADY_USED');
|
||||
}
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(
|
||||
verificationToken.metadata,
|
||||
);
|
||||
|
||||
if (!tokenMetadata.success) {
|
||||
console.error('Invalid token metadata', tokenMetadata.error);
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
const user = verificationToken.user;
|
||||
|
||||
const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'id',
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
});
|
||||
|
||||
const organisationMember = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
const oauthConfig = tokenMetadata.data.oauthConfig;
|
||||
|
||||
const userAlreadyLinked = user.accounts.find(
|
||||
(account) =>
|
||||
account.provider === clientOptions.id &&
|
||||
account.providerAccountId === oauthConfig.providerAccountId,
|
||||
);
|
||||
|
||||
if (organisationMember && userAlreadyLinked) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Link the user if not linked yet.
|
||||
if (!userAlreadyLinked) {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: oauthConfig.providerAccountId,
|
||||
access_token: oauthConfig.accessToken,
|
||||
expires_at: oauthConfig.expiresAt,
|
||||
token_type: 'Bearer',
|
||||
id_token: oauthConfig.idToken,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
|
||||
},
|
||||
});
|
||||
|
||||
// If account already exists in an unverified state, remove the password to ensure
|
||||
// they cannot sign in using that method since we cannot confirm the password
|
||||
// was set by the user.
|
||||
if (!user.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
// Todo: (RR7) Will need to update the "password" account after the migration.
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the user to the organisation if they are not already a member.
|
||||
if (!organisationMember) {
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole:
|
||||
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,119 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationAccountLinkConfirmationTemplate } from '@documenso/email/templates/organisation-account-link-confirmation';
|
||||
import { getI18nInstance } from '@documenso/lib/client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '@documenso/lib/constants/email';
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailContext } from '@documenso/lib/server-only/email/get-email-context';
|
||||
import type { TOrganisationAccountLinkMetadata } from '@documenso/lib/types/organisation';
|
||||
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
|
||||
organisationName: string;
|
||||
};
|
||||
|
||||
export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
type,
|
||||
userId,
|
||||
organisationId,
|
||||
organisationName,
|
||||
oauthConfig,
|
||||
}: SendOrganisationAccountLinkConfirmationEmailProps) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
verificationTokens: {
|
||||
where: {
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
const [previousVerificationToken] = user.verificationTokens;
|
||||
|
||||
// If we've sent a token in the last 5 minutes, don't send another one
|
||||
if (
|
||||
previousVerificationToken?.createdAt &&
|
||||
DateTime.fromJSDate(previousVerificationToken.createdAt).diffNow('minutes').minutes > -5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
token,
|
||||
expires: DateTime.now().plus({ minutes: 30 }).toJSDate(),
|
||||
metadata: {
|
||||
type,
|
||||
userId,
|
||||
organisationId,
|
||||
oauthConfig,
|
||||
} satisfies TOrganisationAccountLinkMetadata,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const { emailLanguage } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
organisationId,
|
||||
},
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/organisation/sso/confirmation/${createdToken.token}`;
|
||||
|
||||
const confirmationTemplate = createElement(OrganisationAccountLinkConfirmationTemplate, {
|
||||
type,
|
||||
assetBaseUrl,
|
||||
confirmationLink,
|
||||
organisationName,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage }),
|
||||
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject:
|
||||
type === 'create'
|
||||
? i18n._(msg`Account creation request`)
|
||||
: i18n._(msg`Account linking request`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
BIN
packages/email/static/building-2.png
Normal file
BIN
packages/email/static/building-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 B |
@ -0,0 +1,145 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
type OrganisationAccountLinkConfirmationTemplateProps = {
|
||||
type: 'create' | 'link';
|
||||
confirmationLink: string;
|
||||
organisationName: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const OrganisationAccountLinkConfirmationTemplate = ({
|
||||
type = 'link',
|
||||
confirmationLink = '<CONFIRMATION_LINK>',
|
||||
organisationName = '<ORGANISATION_NAME>',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: OrganisationAccountLinkConfirmationTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText =
|
||||
type === 'create'
|
||||
? msg`A request has been made to create an account for you`
|
||||
: msg`A request has been made to link your Documenso account`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto h-12 w-12"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="building-2.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
{type === 'create' ? (
|
||||
<Trans>Account creation request</Trans>
|
||||
) : (
|
||||
<Trans>Link your Documenso account</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-base">
|
||||
{type === 'create' ? (
|
||||
<Trans>
|
||||
<span className="font-bold">{organisationName}</span> has requested to create an
|
||||
account on your behalf.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
<span className="font-bold">{organisationName}</span> has requested to link your
|
||||
current Documenso account to their organisation.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Placeholder text if we want to have the warning in the email as well. */}
|
||||
{/* <Section className="mt-6">
|
||||
<Text className="my-0 text-sm">
|
||||
<Trans>
|
||||
By accepting this request, you will be granting{' '}
|
||||
<strong>{organisationName}</strong> full access to:
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="mb-0 mt-2">
|
||||
<li className="text-sm">
|
||||
<Trans>Your account, and everything associated with it</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
<Trans>Something something something</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
<Trans>Something something something</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Text className="mt-2 text-sm">
|
||||
<Trans>
|
||||
You can unlink your account at any time in your security settings on Documenso{' '}
|
||||
<Link href={`${assetBaseUrl}/settings/security/linked-accounts`}>here.</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section> */}
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={confirmationLink}
|
||||
>
|
||||
<Trans>Review request</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs text-slate-500">
|
||||
<Trans>Link expires in 30 minutes.</Trans>
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganisationAccountLinkConfirmationTemplate;
|
||||
31
packages/lib/client-only/hooks/use-autosave.ts
Normal file
31
packages/lib/client-only/hooks/use-autosave.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const saveFormData = async (data: T) => {
|
||||
try {
|
||||
await onSave(data);
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSave = useCallback((data: T) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { scheduleSave };
|
||||
};
|
||||
@ -23,6 +23,9 @@ export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
|
||||
|
||||
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
|
||||
ACCOUNT_SSO_LINK: 'Linked account to SSO',
|
||||
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
|
||||
ORGANISATION_SSO_LINK: 'Linked account to organisation',
|
||||
ORGANISATION_SSO_UNLINK: 'Unlinked account from organisation',
|
||||
ACCOUNT_PROFILE_UPDATE: 'Profile updated',
|
||||
AUTH_2FA_DISABLE: '2FA Disabled',
|
||||
AUTH_2FA_ENABLE: '2FA Enabled',
|
||||
|
||||
@ -16,3 +16,5 @@ export const EMAIL_VERIFICATION_STATE = {
|
||||
EXPIRED: 'EXPIRED',
|
||||
ALREADY_VERIFIED: 'ALREADY_VERIFIED',
|
||||
} as const;
|
||||
|
||||
export const USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER = 'confirmation-email';
|
||||
|
||||
@ -126,3 +126,7 @@ export const PROTECTED_ORGANISATION_URLS = [
|
||||
export const isOrganisationUrlProtected = (url: string) => {
|
||||
return PROTECTED_ORGANISATION_URLS.some((protectedUrl) => url.startsWith(`/${protectedUrl}`));
|
||||
};
|
||||
|
||||
export const ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER = 'organisation-account-link';
|
||||
|
||||
export const ORGANISATION_USER_ACCOUNT_TYPE = 'org-oidc';
|
||||
|
||||
@ -8,7 +8,10 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { env } from '../../utils/env';
|
||||
import {
|
||||
DOCUMENSO_INTERNAL_EMAIL,
|
||||
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
} from '../../constants/email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
|
||||
export interface SendConfirmationEmailProps {
|
||||
@ -16,15 +19,15 @@ export interface SendConfirmationEmailProps {
|
||||
}
|
||||
|
||||
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
|
||||
const NEXT_PRIVATE_SMTP_FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME');
|
||||
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS');
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
verificationTokens: {
|
||||
where: {
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
@ -41,8 +44,6 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
const senderAddress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
|
||||
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
@ -61,10 +62,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: {
|
||||
name: senderName,
|
||||
address: senderAddress,
|
||||
},
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject: i18n._(msg`Please confirm your email`),
|
||||
html,
|
||||
text,
|
||||
|
||||
28
packages/lib/server-only/cert/cert-status.ts
Normal file
28
packages/lib/server-only/cert/cert-status.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
export const getCertificateStatus = () => {
|
||||
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
const defaultPath =
|
||||
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
||||
|
||||
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return { isAvailable: stats.size > 0 };
|
||||
} catch {
|
||||
return { isAvailable: false };
|
||||
}
|
||||
};
|
||||
@ -91,6 +91,12 @@ export const getDocumentAndSenderByToken = async ({
|
||||
select: {
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
brandingEnabled: true,
|
||||
brandingLogo: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -148,33 +148,6 @@ export const sendDocument = async ({
|
||||
// throw new Error('Some signers have not been assigned a signature field.');
|
||||
// }
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
// Only send email if one of the following is true:
|
||||
// - It is explicitly set
|
||||
// - The email is enabled for signing requests AND sendEmail is undefined
|
||||
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
|
||||
await Promise.all(
|
||||
recipientsToNotify.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId,
|
||||
documentId,
|
||||
recipientId: recipient.id,
|
||||
requestMetadata: requestMetadata?.requestMetadata,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const allRecipientsHaveNoActionToTake = document.recipients.every(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
|
||||
@ -227,6 +200,33 @@ export const sendDocument = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
// Only send email if one of the following is true:
|
||||
// - It is explicitly set
|
||||
// - The email is enabled for signing requests AND sendEmail is undefined
|
||||
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
|
||||
await Promise.all(
|
||||
recipientsToNotify.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId,
|
||||
documentId,
|
||||
recipientId: recipient.id,
|
||||
requestMetadata: requestMetadata?.requestMetadata,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -23,11 +24,7 @@ export const acceptOrganisationInvitation = async ({
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
groups: {
|
||||
include: {
|
||||
teamGroups: true,
|
||||
},
|
||||
},
|
||||
groups: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -45,6 +42,9 @@ export const acceptOrganisationInvitation = async ({
|
||||
where: {
|
||||
email: organisationMemberInvite.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@ -55,10 +55,49 @@ export const acceptOrganisationInvitation = async ({
|
||||
|
||||
const { organisation } = organisationMemberInvite;
|
||||
|
||||
const organisationGroupToUse = organisation.groups.find(
|
||||
const isUserPartOfOrganisation = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (isUserPartOfOrganisation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole: organisationMemberInvite.organisationRole,
|
||||
});
|
||||
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const addUserToOrganisation = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
organisationGroups,
|
||||
organisationMemberRole,
|
||||
}: {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
organisationGroups: OrganisationGroup[];
|
||||
organisationMemberRole: OrganisationMemberRole;
|
||||
}) => {
|
||||
const organisationGroupToUse = organisationGroups.find(
|
||||
(group) =>
|
||||
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
|
||||
group.organisationRole === organisationMemberInvite.organisationRole,
|
||||
group.organisationRole === organisationMemberRole,
|
||||
);
|
||||
|
||||
if (!organisationGroupToUse) {
|
||||
@ -72,8 +111,8 @@ export const acceptOrganisationInvitation = async ({
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member'),
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
userId,
|
||||
organisationId,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
@ -83,20 +122,11 @@ export const acceptOrganisationInvitation = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId: organisation.id,
|
||||
memberUserId: user.id,
|
||||
organisationId,
|
||||
memberUserId: userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@ -75,6 +75,16 @@ export const createOrganisation = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const organisationAuthenticationPortal = await tx.organisationAuthenticationPortal.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_sso'),
|
||||
enabled: false,
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
wellKnownUrl: '',
|
||||
},
|
||||
});
|
||||
|
||||
const orgIdAndUrl = prefixedId('org');
|
||||
|
||||
const organisation = await tx.organisation
|
||||
@ -87,6 +97,7 @@ export const createOrganisation = async ({
|
||||
ownerUserId: userId,
|
||||
organisationGlobalSettingsId: organisationSetting.id,
|
||||
organisationClaimId: organisationClaim.id,
|
||||
organisationAuthenticationPortalId: organisationAuthenticationPortal.id,
|
||||
groups: {
|
||||
create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({
|
||||
...group,
|
||||
|
||||
108
packages/lib/server-only/recipient/get-recipient-suggestions.ts
Normal file
108
packages/lib/server-only/recipient/get-recipient-suggestions.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetRecipientSuggestionsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const getRecipientSuggestions = async ({
|
||||
userId,
|
||||
teamId,
|
||||
query,
|
||||
}: GetRecipientSuggestionsOptions) => {
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
const nameEmailFilter = trimmedQuery
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: trimmedQuery,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
{
|
||||
email: {
|
||||
contains: trimmedQuery,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
document: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
...nameEmailFilter,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
document: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
distinct: ['email'],
|
||||
orderBy: {
|
||||
document: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
if (teamId) {
|
||||
const teamMembers = await prisma.organisationMember.findMany({
|
||||
where: {
|
||||
user: {
|
||||
...nameEmailFilter,
|
||||
NOT: { id: userId },
|
||||
},
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
group: {
|
||||
teamGroups: {
|
||||
some: { teamId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
const uniqueTeamMember = teamMembers.find(
|
||||
(member) => !recipients.some((r) => r.email === member.user.email),
|
||||
);
|
||||
|
||||
if (uniqueTeamMember) {
|
||||
const teamMemberSuggestion = {
|
||||
email: uniqueTeamMember.user.email,
|
||||
name: uniqueTeamMember.user.name,
|
||||
};
|
||||
|
||||
const allSuggestions = [...recipients.slice(0, 4), teamMemberSuggestion];
|
||||
|
||||
return allSuggestions;
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
};
|
||||
@ -1,5 +1,7 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface GetRecipientsForTemplateOptions {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
template: teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
template: {
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ONE_HOUR } from '../../constants/time';
|
||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||
|
||||
const IDENTIFIER = 'confirmation-email';
|
||||
|
||||
export const generateConfirmationToken = async ({ email }: { email: string }) => {
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: IDENTIFIER,
|
||||
token: token,
|
||||
expires: new Date(Date.now() + ONE_HOUR),
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdToken) {
|
||||
throw new Error(`Failed to create the verification token`);
|
||||
}
|
||||
|
||||
return sendConfirmationEmail({ userId: user.id });
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
|
||||
|
||||
export type getMostRecentEmailVerificationTokenOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getMostRecentEmailVerificationToken = async ({
|
||||
userId,
|
||||
}: getMostRecentEmailVerificationTokenOptions) => {
|
||||
return await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,18 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetMostRecentVerificationTokenByUserIdOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getMostRecentVerificationTokenByUserId = async ({
|
||||
userId,
|
||||
}: GetMostRecentVerificationTokenByUserIdOptions) => {
|
||||
return await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -3,11 +3,10 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
|
||||
import { ONE_HOUR } from '../../constants/time';
|
||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
|
||||
|
||||
const IDENTIFIER = 'confirmation-email';
|
||||
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
|
||||
|
||||
type SendConfirmationTokenOptions = { email: string; force?: boolean };
|
||||
|
||||
@ -31,7 +30,7 @@ export const sendConfirmationToken = async ({
|
||||
throw new Error('Email verified');
|
||||
}
|
||||
|
||||
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
|
||||
const mostRecentToken = await getMostRecentEmailVerificationToken({ userId: user.id });
|
||||
|
||||
// If we've sent a token in the last 5 minutes, don't send another one
|
||||
if (
|
||||
@ -44,7 +43,7 @@ export const sendConfirmationToken = async ({
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: IDENTIFIER,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
token: token,
|
||||
expires: new Date(Date.now() + ONE_HOUR),
|
||||
user: {
|
||||
|
||||
@ -2,7 +2,10 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { EMAIL_VERIFICATION_STATE } from '../../constants/email';
|
||||
import {
|
||||
EMAIL_VERIFICATION_STATE,
|
||||
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
} from '../../constants/email';
|
||||
import { jobsClient } from '../../jobs/client';
|
||||
|
||||
export type VerifyEmailProps = {
|
||||
@ -22,6 +25,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
},
|
||||
where: {
|
||||
token,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
|
||||
import { OrganisationSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
|
||||
@ -43,3 +43,19 @@ export const ZOrganisationLiteSchema = OrganisationSchema.pick({
|
||||
* A version of the organisation response schema when returning multiple organisations at once from a single API endpoint.
|
||||
*/
|
||||
export const ZOrganisationManySchema = ZOrganisationLiteSchema;
|
||||
|
||||
export const ZOrganisationAccountLinkMetadataSchema = z.object({
|
||||
type: z.enum(['link', 'create']),
|
||||
userId: z.number(),
|
||||
organisationId: z.string(),
|
||||
oauthConfig: z.object({
|
||||
providerAccountId: z.string(),
|
||||
accessToken: z.string(),
|
||||
expiresAt: z.number(),
|
||||
idToken: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TOrganisationAccountLinkMetadata = z.infer<
|
||||
typeof ZOrganisationAccountLinkMetadataSchema
|
||||
>;
|
||||
|
||||
@ -28,6 +28,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
embedSigningWhiteLabel: z.boolean().optional(),
|
||||
|
||||
cfr21: z.boolean().optional(),
|
||||
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
|
||||
@ -76,6 +78,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'cfr21',
|
||||
label: '21 CFR',
|
||||
},
|
||||
authenticationPortal: {
|
||||
key: 'authenticationPortal',
|
||||
label: 'Authentication portal',
|
||||
},
|
||||
};
|
||||
|
||||
export enum INTERNAL_CLAIM_ID {
|
||||
@ -157,6 +163,7 @@ export const internalClaims: InternalClaims = {
|
||||
embedSigning: true,
|
||||
embedSigningWhiteLabel: true,
|
||||
cfr21: true,
|
||||
authenticationPortal: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {
|
||||
|
||||
@ -16,6 +16,7 @@ type DatabaseIdPrefix =
|
||||
| 'org_email'
|
||||
| 'org_claim'
|
||||
| 'org_group'
|
||||
| 'org_sso'
|
||||
| 'org_setting'
|
||||
| 'member'
|
||||
| 'member_invite'
|
||||
|
||||
13
packages/lib/utils/organisation-authentication-portal.ts
Normal file
13
packages/lib/utils/organisation-authentication-portal.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
export const formatOrganisationLoginUrl = (organisationUrl: string) => {
|
||||
return NEXT_PUBLIC_WEBAPP_URL() + formatOrganisationLoginPath(organisationUrl);
|
||||
};
|
||||
|
||||
export const formatOrganisationLoginPath = (organisationUrl: string) => {
|
||||
return `/o/${organisationUrl}/signin`;
|
||||
};
|
||||
|
||||
export const formatOrganisationCallbackUrl = (organisationUrl: string) => {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/oidc/org/${organisationUrl}`;
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[organisationAuthenticationPortalId]` on the table `Organisation` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `organisationAuthenticationPortalId` to the `Organisation` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ACCOUNT_SSO_UNLINK';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_LINK';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_UNLINK';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- [CUSTOM_CHANGE] This is supposed to be NOT NULL but we reapply it at the end.
|
||||
ALTER TABLE "Organisation" ADD COLUMN "organisationAuthenticationPortalId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "VerificationToken" ADD COLUMN "metadata" JSONB;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganisationAuthenticationPortal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"clientId" TEXT NOT NULL DEFAULT '',
|
||||
"clientSecret" TEXT NOT NULL DEFAULT '',
|
||||
"wellKnownUrl" TEXT NOT NULL DEFAULT '',
|
||||
"defaultOrganisationRole" "OrganisationMemberRole" NOT NULL DEFAULT 'MEMBER',
|
||||
"autoProvisionUsers" BOOLEAN NOT NULL DEFAULT true,
|
||||
"allowedDomains" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"organisationId" TEXT, -- [CUSTOM_CHANGE] This is a temporary column for migration purposes.
|
||||
|
||||
CONSTRAINT "OrganisationAuthenticationPortal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- [CUSTOM_CHANGE] Create default OrganisationAuthenticationPortal for all organisations
|
||||
INSERT INTO "OrganisationAuthenticationPortal" ("id", "enabled", "clientId", "clientSecret", "wellKnownUrl", "defaultOrganisationRole", "autoProvisionUsers", "allowedDomains", "organisationId")
|
||||
SELECT
|
||||
generate_prefix_id('org_sso'),
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'MEMBER',
|
||||
true,
|
||||
ARRAY[]::TEXT[],
|
||||
o."id"
|
||||
FROM "Organisation" o
|
||||
WHERE o."organisationAuthenticationPortalId" IS NULL;
|
||||
|
||||
-- [CUSTOM_CHANGE] Update organisations with their corresponding organisationAuthenticationPortalId
|
||||
UPDATE "Organisation" o
|
||||
SET "organisationAuthenticationPortalId" = oap."id"
|
||||
FROM "OrganisationAuthenticationPortal" oap
|
||||
WHERE oap."organisationId" = o."id" AND o."organisationAuthenticationPortalId" IS NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Organisation_organisationAuthenticationPortalId_key" ON "Organisation"("organisationAuthenticationPortalId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Organisation" ADD CONSTRAINT "Organisation_organisationAuthenticationPortalId_fkey" FOREIGN KEY ("organisationAuthenticationPortalId") REFERENCES "OrganisationAuthenticationPortal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- [CUSTOM_CHANGE] Reapply NOT NULL constraint.
|
||||
ALTER TABLE "Organisation" ALTER COLUMN "organisationAuthenticationPortalId" SET NOT NULL;
|
||||
|
||||
-- [CUSTOM_CHANGE] Drop temporary column.
|
||||
ALTER TABLE "OrganisationAuthenticationPortal" DROP COLUMN "organisationId";
|
||||
@ -90,6 +90,9 @@ model TeamProfile {
|
||||
enum UserSecurityAuditLogType {
|
||||
ACCOUNT_PROFILE_UPDATE
|
||||
ACCOUNT_SSO_LINK
|
||||
ACCOUNT_SSO_UNLINK
|
||||
ORGANISATION_SSO_LINK
|
||||
ORGANISATION_SSO_UNLINK
|
||||
AUTH_2FA_DISABLE
|
||||
AUTH_2FA_ENABLE
|
||||
PASSKEY_CREATED
|
||||
@ -157,6 +160,7 @@ model VerificationToken {
|
||||
completed Boolean @default(false)
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
metadata Json?
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@ -277,13 +281,15 @@ model OrganisationClaim {
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
// When this record was created, unrelated to anything passed back by the provider.
|
||||
createdAt DateTime @default(now())
|
||||
userId Int
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
// Some providers return created_at so we need to make it optional
|
||||
created_at Int?
|
||||
@ -291,7 +297,7 @@ model Account {
|
||||
ext_expires_in Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
password String?
|
||||
|
||||
@ -632,6 +638,9 @@ model Organisation {
|
||||
|
||||
organisationGlobalSettingsId String @unique
|
||||
organisationGlobalSettings OrganisationGlobalSettings @relation(fields: [organisationGlobalSettingsId], references: [id])
|
||||
|
||||
organisationAuthenticationPortalId String @unique
|
||||
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
|
||||
}
|
||||
|
||||
model OrganisationMember {
|
||||
@ -1026,3 +1035,18 @@ model OrganisationEmail {
|
||||
organisationGlobalSettings OrganisationGlobalSettings[]
|
||||
teamGlobalSettings TeamGlobalSettings[]
|
||||
}
|
||||
|
||||
model OrganisationAuthenticationPortal {
|
||||
id String @id
|
||||
organisation Organisation?
|
||||
|
||||
enabled Boolean @default(false)
|
||||
|
||||
clientId String @default("")
|
||||
clientSecret String @default("")
|
||||
wellKnownUrl String @default("")
|
||||
|
||||
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
|
||||
autoProvisionUsers Boolean @default(true)
|
||||
allowedDomains String[] @default([])
|
||||
}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import type { OrganisationMemberRole, OrganisationType } from '@prisma/client';
|
||||
import { OrganisationMemberInviteStatus, type User } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { OrganisationGroupType, type User } from '@prisma/client';
|
||||
|
||||
import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
|
||||
import { prisma } from '..';
|
||||
import { seedTestEmail } from './users';
|
||||
@ -27,6 +25,13 @@ export const seedOrganisationMembers = async ({
|
||||
|
||||
const createdMembers: User[] = [];
|
||||
|
||||
const organisationGroups = await prisma.organisationGroup.findMany({
|
||||
where: {
|
||||
organisationId,
|
||||
type: OrganisationGroupType.INTERNAL_ORGANISATION,
|
||||
},
|
||||
});
|
||||
|
||||
for (const member of members) {
|
||||
const email = member.email ?? seedTestEmail();
|
||||
|
||||
@ -53,33 +58,15 @@ export const seedOrganisationMembers = async ({
|
||||
email: newUser.email,
|
||||
organisationRole: member.organisationRole,
|
||||
});
|
||||
|
||||
await addUserToOrganisation({
|
||||
userId: newUser.id,
|
||||
organisationId,
|
||||
organisationGroups,
|
||||
organisationMemberRole: member.organisationRole,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationMemberInvite.createMany({
|
||||
data: membersToInvite.map((invite) => ({
|
||||
id: prefixedId('member_invite'),
|
||||
email: invite.email,
|
||||
organisationId,
|
||||
organisationRole: invite.organisationRole,
|
||||
token: nanoid(32),
|
||||
})),
|
||||
});
|
||||
|
||||
const invites = await prisma.organisationMemberInvite.findMany({
|
||||
where: {
|
||||
organisationId,
|
||||
status: OrganisationMemberInviteStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
invites.map(async (invite) => {
|
||||
await acceptOrganisationInvitation({
|
||||
token: invite.token,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return createdMembers;
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { signWithP12 } from '@documenso/pdf-sign';
|
||||
|
||||
@ -22,12 +23,23 @@ export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
|
||||
|
||||
const signatureLength = byteRange[2] - byteRange[1];
|
||||
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
if (!certStatus.isAvailable) {
|
||||
console.error('Certificate error: Certificate not available for document signing');
|
||||
throw new Error('Document signing failed: Certificate not available');
|
||||
}
|
||||
|
||||
let cert: Buffer | null = null;
|
||||
|
||||
const localFileContents = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS');
|
||||
|
||||
if (localFileContents) {
|
||||
cert = Buffer.from(localFileContents, 'base64');
|
||||
try {
|
||||
cert = Buffer.from(localFileContents, 'base64');
|
||||
} catch {
|
||||
throw new Error('Failed to decode certificate contents');
|
||||
}
|
||||
}
|
||||
|
||||
if (!cert) {
|
||||
@ -42,7 +54,12 @@ export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
|
||||
certPath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || './example/cert.p12';
|
||||
}
|
||||
|
||||
cert = Buffer.from(fs.readFileSync(certPath));
|
||||
try {
|
||||
cert = Buffer.from(fs.readFileSync(certPath));
|
||||
} catch {
|
||||
console.error('Certificate error: Failed to read certificate file');
|
||||
throw new Error('Document signing failed: Certificate file not accessible');
|
||||
}
|
||||
}
|
||||
|
||||
const signature = signWithP12({
|
||||
|
||||
@ -28,6 +28,7 @@ export const ZDocumentTitleSchema = z
|
||||
export const ZDocumentExternalIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(255)
|
||||
.describe('The external ID of the document.');
|
||||
|
||||
export const ZDocumentVisibilitySchema = z
|
||||
@ -65,10 +66,12 @@ export const ZDocumentMetaLanguageSchema = z
|
||||
|
||||
export const ZDocumentMetaSubjectSchema = z
|
||||
.string()
|
||||
.max(254)
|
||||
.describe('The subject of the email that will be sent to the recipients.');
|
||||
|
||||
export const ZDocumentMetaMessageSchema = z
|
||||
.string()
|
||||
.max(5000)
|
||||
.describe('The message of the email that will be sent to the recipients.');
|
||||
|
||||
export const ZDocumentMetaDistributionMethodSchema = z
|
||||
|
||||
@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain';
|
||||
|
||||
const domainRegex =
|
||||
export const domainRegex =
|
||||
/^(?!https?:\/\/)(?!www\.)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
|
||||
export const ZDomainSchema = z
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZDeclineLinkOrganisationAccountRequestSchema,
|
||||
ZDeclineLinkOrganisationAccountResponseSchema,
|
||||
} from './decline-link-organisation-account.types';
|
||||
|
||||
/**
|
||||
* Unauthenicated procedure, do not copy paste.
|
||||
*/
|
||||
export const declineLinkOrganisationAccountRoute = procedure
|
||||
.input(ZDeclineLinkOrganisationAccountRequestSchema)
|
||||
.output(ZDeclineLinkOrganisationAccountResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { token } = input;
|
||||
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeclineLinkOrganisationAccountRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeclineLinkOrganisationAccountResponseSchema = z.void();
|
||||
|
||||
export type TDeclineLinkOrganisationAccountRequest = z.infer<
|
||||
typeof ZDeclineLinkOrganisationAccountRequestSchema
|
||||
>;
|
||||
@ -0,0 +1,84 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetOrganisationAuthenticationPortalRequestSchema,
|
||||
ZGetOrganisationAuthenticationPortalResponseSchema,
|
||||
} from './get-organisation-authentication-portal.types';
|
||||
|
||||
export const getOrganisationAuthenticationPortalRoute = authenticatedProcedure
|
||||
.input(ZGetOrganisationAuthenticationPortalRequestSchema)
|
||||
.output(ZGetOrganisationAuthenticationPortalResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { organisationId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
return await getOrganisationAuthenticationPortal({
|
||||
userId: ctx.user.id,
|
||||
organisationId,
|
||||
});
|
||||
});
|
||||
|
||||
type GetOrganisationAuthenticationPortalOptions = {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const getOrganisationAuthenticationPortal = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
}: GetOrganisationAuthenticationPortalOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
organisationAuthenticationPortal: {
|
||||
select: {
|
||||
defaultOrganisationRole: true,
|
||||
enabled: true,
|
||||
clientId: true,
|
||||
wellKnownUrl: true,
|
||||
autoProvisionUsers: true,
|
||||
allowedDomains: true,
|
||||
clientSecret: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.authenticationPortal) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Authentication portal not found',
|
||||
});
|
||||
}
|
||||
|
||||
const portal = organisation.organisationAuthenticationPortal;
|
||||
|
||||
return {
|
||||
defaultOrganisationRole: portal.defaultOrganisationRole,
|
||||
enabled: portal.enabled,
|
||||
clientId: portal.clientId,
|
||||
wellKnownUrl: portal.wellKnownUrl,
|
||||
autoProvisionUsers: portal.autoProvisionUsers,
|
||||
allowedDomains: portal.allowedDomains,
|
||||
clientSecretProvided: Boolean(portal.clientSecret),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OrganisationAuthenticationPortalSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationAuthenticationPortalSchema';
|
||||
|
||||
export const ZGetOrganisationAuthenticationPortalRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZGetOrganisationAuthenticationPortalResponseSchema =
|
||||
OrganisationAuthenticationPortalSchema.pick({
|
||||
defaultOrganisationRole: true,
|
||||
enabled: true,
|
||||
clientId: true,
|
||||
wellKnownUrl: true,
|
||||
autoProvisionUsers: true,
|
||||
allowedDomains: true,
|
||||
}).extend({
|
||||
/**
|
||||
* Whether we have the client secret in the database.
|
||||
*
|
||||
* Do not expose the actual client secret.
|
||||
*/
|
||||
clientSecretProvided: z.boolean(),
|
||||
});
|
||||
|
||||
export type TGetOrganisationAuthenticationPortalResponse = z.infer<
|
||||
typeof ZGetOrganisationAuthenticationPortalResponseSchema
|
||||
>;
|
||||
@ -0,0 +1,22 @@
|
||||
import { linkOrganisationAccount } from '@documenso/ee/server-only/lib/link-organisation-account';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZLinkOrganisationAccountRequestSchema,
|
||||
ZLinkOrganisationAccountResponseSchema,
|
||||
} from './link-organisation-account.types';
|
||||
|
||||
/**
|
||||
* Unauthenicated procedure, do not copy paste.
|
||||
*/
|
||||
export const linkOrganisationAccountRoute = procedure
|
||||
.input(ZLinkOrganisationAccountRequestSchema)
|
||||
.output(ZLinkOrganisationAccountResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token } = input;
|
||||
|
||||
await linkOrganisationAccount({
|
||||
token,
|
||||
requestMeta: ctx.metadata.requestMetadata,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZLinkOrganisationAccountRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const ZLinkOrganisationAccountResponseSchema = z.void();
|
||||
|
||||
export type TLinkOrganisationAccountRequest = z.infer<typeof ZLinkOrganisationAccountRequestSchema>;
|
||||
@ -2,15 +2,19 @@ import { router } from '../trpc';
|
||||
import { createOrganisationEmailRoute } from './create-organisation-email';
|
||||
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
|
||||
import { createSubscriptionRoute } from './create-subscription';
|
||||
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
|
||||
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
|
||||
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
|
||||
import { findOrganisationEmailDomainsRoute } from './find-organisation-email-domain';
|
||||
import { findOrganisationEmailsRoute } from './find-organisation-emails';
|
||||
import { getInvoicesRoute } from './get-invoices';
|
||||
import { getOrganisationAuthenticationPortalRoute } from './get-organisation-authentication-portal';
|
||||
import { getOrganisationEmailDomainRoute } from './get-organisation-email-domain';
|
||||
import { getPlansRoute } from './get-plans';
|
||||
import { getSubscriptionRoute } from './get-subscription';
|
||||
import { linkOrganisationAccountRoute } from './link-organisation-account';
|
||||
import { manageSubscriptionRoute } from './manage-subscription';
|
||||
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
|
||||
import { updateOrganisationEmailRoute } from './update-organisation-email';
|
||||
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
|
||||
|
||||
@ -29,6 +33,12 @@ export const enterpriseRouter = router({
|
||||
delete: deleteOrganisationEmailDomainRoute,
|
||||
verify: verifyOrganisationEmailDomainRoute,
|
||||
},
|
||||
authenticationPortal: {
|
||||
get: getOrganisationAuthenticationPortalRoute,
|
||||
update: updateOrganisationAuthenticationPortalRoute,
|
||||
linkAccount: linkOrganisationAccountRoute,
|
||||
declineLinkAccount: declineLinkOrganisationAccountRoute,
|
||||
},
|
||||
},
|
||||
billing: {
|
||||
plans: {
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZUpdateOrganisationAuthenticationPortalRequestSchema,
|
||||
ZUpdateOrganisationAuthenticationPortalResponseSchema,
|
||||
} from './update-organisation-authentication-portal.types';
|
||||
|
||||
export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedure
|
||||
.input(ZUpdateOrganisationAuthenticationPortalRequestSchema)
|
||||
.output(ZUpdateOrganisationAuthenticationPortalResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { organisationId, data } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Billing is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId: user.id,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
organisationAuthenticationPortal: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.authenticationPortal) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Authentication portal is not allowed for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
defaultOrganisationRole,
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers,
|
||||
allowedDomains,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
enabled &&
|
||||
(!wellKnownUrl ||
|
||||
!clientId ||
|
||||
(!clientSecret && !organisation.organisationAuthenticationPortal.clientSecret))
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message:
|
||||
'Client ID, client secret, and well known URL are required when authentication portal is enabled',
|
||||
});
|
||||
}
|
||||
|
||||
// Allow empty string to be passed in to remove the client secret from the database.
|
||||
let encryptedClientSecret: string | undefined = clientSecret;
|
||||
|
||||
// Encrypt the secret if it is provided.
|
||||
if (clientSecret) {
|
||||
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
encryptedClientSecret = symmetricEncrypt({
|
||||
key: encryptionKey,
|
||||
data: clientSecret,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationAuthenticationPortal.update({
|
||||
where: {
|
||||
id: organisation.organisationAuthenticationPortal.id,
|
||||
},
|
||||
data: {
|
||||
defaultOrganisationRole,
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret: encryptedClientSecret,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers,
|
||||
allowedDomains,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import OrganisationMemberRoleSchema from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
|
||||
|
||||
import { domainRegex } from './create-organisation-email-domain.types';
|
||||
|
||||
export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
data: z.object({
|
||||
defaultOrganisationRole: OrganisationMemberRoleSchema,
|
||||
enabled: z.boolean(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
|
||||
autoProvisionUsers: z.boolean(),
|
||||
allowedDomains: z.array(z.string().regex(domainRegex)),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateOrganisationAuthenticationPortalResponseSchema = z.void();
|
||||
|
||||
export type TUpdateOrganisationAuthenticationPortalRequest = z.infer<
|
||||
typeof ZUpdateOrganisationAuthenticationPortalRequestSchema
|
||||
>;
|
||||
@ -1,4 +1,7 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import {
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -37,9 +40,18 @@ export const deleteOrganisationRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.deleteMany({
|
||||
where: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { getRecipientSuggestions } from '@documenso/lib/server-only/recipient/get-recipient-suggestions';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetRecipientSuggestionsRequestSchema,
|
||||
ZGetRecipientSuggestionsResponseSchema,
|
||||
} from './find-recipient-suggestions.types';
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export const findRecipientSuggestionsRoute = authenticatedProcedure
|
||||
.input(ZGetRecipientSuggestionsRequestSchema)
|
||||
.output(ZGetRecipientSuggestionsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { query } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = await getRecipientSuggestions({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
query,
|
||||
});
|
||||
|
||||
return {
|
||||
results: suggestions,
|
||||
};
|
||||
});
|
||||
@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetRecipientSuggestionsRequestSchema = z.object({
|
||||
query: z.string().default(''),
|
||||
});
|
||||
|
||||
export const ZGetRecipientSuggestionsResponseSchema = z.object({
|
||||
results: z.array(
|
||||
z.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TGetRecipientSuggestionsRequestSchema = z.infer<
|
||||
typeof ZGetRecipientSuggestionsRequestSchema
|
||||
>;
|
||||
|
||||
export type TGetRecipientSuggestionsResponseSchema = z.infer<
|
||||
typeof ZGetRecipientSuggestionsResponseSchema
|
||||
>;
|
||||
@ -12,6 +12,7 @@ import { updateTemplateRecipients } from '@documenso/lib/server-only/recipient/u
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
|
||||
import {
|
||||
ZCompleteDocumentWithTokenMutationSchema,
|
||||
ZCreateDocumentRecipientRequestSchema,
|
||||
@ -42,6 +43,10 @@ import {
|
||||
} from './schema';
|
||||
|
||||
export const recipientRouter = router({
|
||||
suggestions: {
|
||||
find: findRecipientSuggestionsRoute,
|
||||
},
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@ -23,8 +23,8 @@ export const ZGetRecipientResponseSchema = ZRecipientSchema;
|
||||
* pass along required details.
|
||||
*/
|
||||
export const ZCreateRecipientSchema = z.object({
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254),
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
|
||||
@ -33,8 +33,8 @@ export const ZCreateRecipientSchema = z.object({
|
||||
|
||||
export const ZUpdateRecipientSchema = z.object({
|
||||
id: z.number().describe('The ID of the recipient to update.'),
|
||||
email: z.string().toLowerCase().email().min(1).optional(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254).optional(),
|
||||
name: z.string().max(255).optional(),
|
||||
role: z.nativeEnum(RecipientRole).optional(),
|
||||
signingOrder: z.number().optional(),
|
||||
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
|
||||
@ -103,8 +103,8 @@ export const ZSetDocumentRecipientsRequestSchema = z
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254),
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
@ -229,8 +229,8 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
||||
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||
nextSigner: z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().max(254),
|
||||
name: z.string().min(1).max(255),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user