diff --git a/SIGNING.md b/SIGNING.md index 3eb94fbfb..cb719ffb8 100644 --- a/SIGNING.md +++ b/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) diff --git a/apps/documentation/pages/developers/self-hosting/how-to.mdx b/apps/documentation/pages/developers/self-hosting/how-to.mdx index 08797e801..cdc5a2c22 100644 --- a/apps/documentation/pages/developers/self-hosting/how-to.mdx +++ b/apps/documentation/pages/developers/self-hosting/how-to.mdx @@ -119,16 +119,89 @@ NEXT_PRIVATE_SMTP_USERNAME="" NEXT_PRIVATE_SMTP_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: + + This is the most common source of issues for self-hosters. Please follow these steps carefully. + -```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 + ``` + + + Your certificate MUST have a password. Certificates without passwords will cause "Failed to get + private key bags" errors. + + +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 diff --git a/apps/documentation/pages/users/compliance/standards-and-regulations.mdx b/apps/documentation/pages/users/compliance/standards-and-regulations.mdx index c1c7845df..4d5193944 100644 --- a/apps/documentation/pages/users/compliance/standards-and-regulations.mdx +++ b/apps/documentation/pages/users/compliance/standards-and-regulations.mdx @@ -19,13 +19,13 @@ device, and other FDA-regulated industries. - [x] User Access Management - [x] Quality Assurance Documentation -## SOC/ SOC II +## SOC 2 - - Status: [Planned](https://github.com/documenso/backlog/issues/24) + + Status: [Compliant](https://documen.so/trust) -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). Status: [Planned](https://github.com/documenso/backlog/issues/26) -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 diff --git a/apps/documentation/pages/users/organisations/_meta.json b/apps/documentation/pages/users/organisations/_meta.json index b77f9137c..dfc75fc95 100644 --- a/apps/documentation/pages/users/organisations/_meta.json +++ b/apps/documentation/pages/users/organisations/_meta.json @@ -3,5 +3,6 @@ "members": "Members", "groups": "Groups", "teams": "Teams", + "sso": "SSO", "billing": "Billing" -} \ No newline at end of file +} diff --git a/apps/documentation/pages/users/organisations/sso/_meta.json b/apps/documentation/pages/users/organisations/sso/_meta.json new file mode 100644 index 000000000..4ba07c6f6 --- /dev/null +++ b/apps/documentation/pages/users/organisations/sso/_meta.json @@ -0,0 +1,4 @@ +{ + "index": "Configuration", + "microsoft-entra-id": "Microsoft Entra ID" +} diff --git a/apps/documentation/pages/users/organisations/sso/index.mdx b/apps/documentation/pages/users/organisations/sso/index.mdx new file mode 100644 index 000000000..c909b3336 --- /dev/null +++ b/apps/documentation/pages/users/organisations/sso/index.mdx @@ -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 + + + Anyone who signs in through your portal will be added to your organisation as a member. + + +## Getting Started + +To set up the SSO Portal, you need to be an organisation owner, admin, or manager. + + + **Enterprise Only**: This feature is only available to Enterprise customers. + + + + +### Access Organisation SSO Settings + +![Organisation SSO Portal settings](/organisations/organisations-sso-settings.webp) + +### 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. + + + +## 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 + + + For additional support for SSO Portal configuration, contact our support team at + support@documenso.com. + + +## 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 diff --git a/apps/documentation/pages/users/organisations/sso/microsoft-entra-id.mdx b/apps/documentation/pages/users/organisations/sso/microsoft-entra-id.mdx new file mode 100644 index 000000000..6b3ed6d65 --- /dev/null +++ b/apps/documentation/pages/users/organisations/sso/microsoft-entra-id.mdx @@ -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 + +Each user in your Azure AD will need an email associated with it. + +## Creating an App Registration + + + +### 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. + + + +## 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 diff --git a/apps/documentation/public/organisations/organisations-sso-settings.webp b/apps/documentation/public/organisations/organisations-sso-settings.webp new file mode 100644 index 000000000..b6d46f77e Binary files /dev/null and b/apps/documentation/public/organisations/organisations-sso-settings.webp differ diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index df73d92b6..e8ffa5fe5 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -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} /> @@ -379,6 +472,7 @@ export const DocumentEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSubjectFormSubmit} + onAutoSave={onAddSubjectFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 3cea126c8..17d7a45a1 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -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} /> diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx index 87326a5fa..1ef247147 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -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; }); diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx new file mode 100644 index 000000000..db6b7c38d --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx @@ -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; + +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 ; + } + + return ( +
+ + + +
+ ); +} + +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({ + 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 ( +
+ +
+
+ + +
+ +
+ toast({ title: t`Copied to clipboard` })} + /> +
+
+ +

+ This is the URL which users will use to sign in to your organisation. +

+
+ +
+ + +
+ +
+ toast({ title: t`Copied to clipboard` })} + /> +
+
+ +

+ Add this URL to your provider's allowed redirect URIs +

+
+ +
+ + + + +

+ This is the required scopes you must set in your provider's settings +

+
+ + ( + + + Issuer URL + + + + + + {!form.formState.errors.wellKnownUrl && ( +

+ The OpenID discovery endpoint URL for your provider +

+ )} + +
+ )} + /> + +
+ ( + + + Client ID + + + + + + + )} + /> + + ( + + + Client Secret + + + + + + + )} + /> +
+ + ( + + + Default Organisation Role for New Users + + + + + + + )} + /> + + ( + + + Allowed Email Domains + + +