Merge branch 'main' into fix/download-certificate-audit-log-safari

This commit is contained in:
Catalin Pit
2025-09-10 14:55:22 +03:00
committed by GitHub
83 changed files with 3667 additions and 297 deletions

View File

@ -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

View File

@ -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

View File

@ -3,5 +3,6 @@
"members": "Members",
"groups": "Groups",
"teams": "Teams",
"sso": "SSO",
"billing": "Billing"
}
}

View File

@ -0,0 +1,4 @@
{
"index": "Configuration",
"microsoft-entra-id": "Microsoft Entra ID"
}

View 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
![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.
</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

View File

@ -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

View File

@ -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;
});

View 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>
);
};

View File

@ -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>
);
}

View File

@ -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>
);
};

View 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>
);
}

View File

@ -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>
);
}

View 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 },
);
}
};

View File

@ -1,22 +1,47 @@
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 },
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B