mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 21:45:18 +10:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ff571a9fa | |||
| 1b1e3d197b | |||
| a276e18e1f | |||
| a3365b9c2d | |||
| 162f246228 | |||
| c5504dd5f3 | |||
| 561a5e3946 | |||
| ff8eebf26c | |||
| 3529999caa | |||
| 23ad742ba4 |
@@ -76,6 +76,8 @@ The Enterprise Edition is required when you:
|
||||
4. Restart your Documenso instance
|
||||
5. Verify the license is active in the **Admin Panel** under the **Stats** section
|
||||
|
||||
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
|
||||
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
|
||||
1. Sign the Enterprise license agreement
|
||||
2. Receive license key and access credentials
|
||||
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
|
||||
4. Configure Enterprise features with support assistance
|
||||
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
|
||||
|
||||
## Related
|
||||
|
||||
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
|
||||
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
|
||||
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
|
||||
- [Support](/docs/policies/support) - Support channels and response times
|
||||
|
||||
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
|
||||
|
||||
## Enterprise Features
|
||||
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------ | ------------------------------------------------ |
|
||||
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
|
||||
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
|
||||
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
|
||||
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
|
||||
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Apply Your License Key
|
||||
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
|
||||
---
|
||||
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
|
||||
|
||||
<Callout type="info">
|
||||
The license key applies to your **whole instance**, not an individual user account. There's one
|
||||
key per deployment.
|
||||
</Callout>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
|
||||
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
|
||||
- A running self-hosted Documenso instance that you're able to restart
|
||||
|
||||
## Step 1: Set the environment variable
|
||||
|
||||
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
|
||||
|
||||
<Tabs items={['Docker Compose', 'docker run', '.env']}>
|
||||
<Tab value="Docker Compose">
|
||||
|
||||
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
|
||||
```
|
||||
|
||||
Then apply it:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value="docker run">
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name documenso \
|
||||
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
|
||||
documenso/documenso:latest
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value=".env">
|
||||
|
||||
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Step 2: Restart the instance
|
||||
|
||||
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose restart documenso
|
||||
|
||||
# Docker
|
||||
docker restart documenso
|
||||
```
|
||||
|
||||
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
|
||||
|
||||
## What the license enables
|
||||
|
||||
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
|
||||
|
||||
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
|
||||
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordions type="multiple">
|
||||
<Accordion title="Enterprise features are still unavailable after applying the key">
|
||||
- Confirm the key is present in the environment the running process actually reads — `docker
|
||||
exec` into the container and check `env | grep LICENSE` if unsure.
|
||||
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
|
||||
- Re-copy the key to rule out truncation or accidental whitespace.
|
||||
</Accordion>
|
||||
<Accordion title="A specific feature still isn't working">
|
||||
Instance-wide features (like CSC signing) also need their own configuration — an active license
|
||||
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
|
||||
Per-organisation features additionally need to be provisioned for the organisation that's using
|
||||
them.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
## See Also
|
||||
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
|
||||
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
|
||||
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
|
||||
@@ -2,6 +2,7 @@
|
||||
"title": "Configuration",
|
||||
"pages": [
|
||||
"environment",
|
||||
"license",
|
||||
"database",
|
||||
"email",
|
||||
"storage",
|
||||
|
||||
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
|
||||
|
||||
### Enterprise Edition license
|
||||
|
||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
|
||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
|
||||
|
||||
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
|
||||
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type BrandingPreferencesResetDialogProps = {
|
||||
disabled?: boolean;
|
||||
hasAdvancedBranding: boolean;
|
||||
isSubmitting: boolean;
|
||||
onReset: () => Promise<void>;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BrandingPreferencesResetDialog = ({
|
||||
disabled = false,
|
||||
hasAdvancedBranding,
|
||||
isSubmitting,
|
||||
onReset,
|
||||
trigger,
|
||||
}: BrandingPreferencesResetDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const isLoading = isSubmitting || isResetting;
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
setIsResetting(true);
|
||||
|
||||
try {
|
||||
await onReset();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && !disabled && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive" type="button" disabled={disabled || isLoading}>
|
||||
<Trans>Reset to defaults</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Reset branding preferences</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
This will reset all branding preferences to their default values and save the changes immediately.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>Once confirmed, the following will be reset:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Custom branding enabled setting</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Branding logo</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Brand website and brand details</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Brand colours, including background, foreground, primary, and border colours</Trans>
|
||||
</li>
|
||||
|
||||
{hasAdvancedBranding && (
|
||||
<>
|
||||
<li>
|
||||
<Trans>Border radius</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Custom CSS</Trans>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isLoading}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
|
||||
<Trans>Reset to defaults</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@documenso/lib/constants/branding';
|
||||
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -23,6 +24,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BrandingPreferencesResetDialog } from '~/components/dialogs/branding-preferences-reset-dialog';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
@@ -74,6 +76,7 @@ export function BrandingPreferencesForm({
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
const [colorPickerKey, setColorPickerKey] = useState(0);
|
||||
|
||||
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
||||
const initialColors = parsedColors.success ? parsedColors.data : {};
|
||||
@@ -96,6 +99,41 @@ export function BrandingPreferencesForm({
|
||||
|
||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||
|
||||
const hasResetBrandingColors =
|
||||
settings.brandingColors === null ||
|
||||
settings.brandingColors === undefined ||
|
||||
(parsedColors.success && normalizeBrandingColors(parsedColors.data) === null);
|
||||
|
||||
const isResetDisabled =
|
||||
!form.formState.isDirty &&
|
||||
settings.brandingEnabled === (canInherit ? null : false) &&
|
||||
!settings.brandingLogo &&
|
||||
!settings.brandingUrl &&
|
||||
!settings.brandingCompanyDetails &&
|
||||
!settings.brandingCss &&
|
||||
hasResetBrandingColors;
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
const data: TBrandingPreferencesFormSchema = {
|
||||
brandingEnabled: canInherit ? null : false,
|
||||
brandingLogo: null,
|
||||
brandingUrl: '',
|
||||
brandingCompanyDetails: '',
|
||||
brandingColors: {},
|
||||
brandingCss: '',
|
||||
};
|
||||
|
||||
await onFormSubmit(data);
|
||||
|
||||
if (previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
|
||||
setPreviewUrl('');
|
||||
setColorPickerKey((key) => key + 1);
|
||||
form.reset(data);
|
||||
};
|
||||
|
||||
const getSavedLogoPreviewUrl = () => {
|
||||
if (!settings.brandingLogo) {
|
||||
return '';
|
||||
@@ -397,6 +435,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`background-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.background}
|
||||
@@ -420,6 +459,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`foreground-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.foreground}
|
||||
@@ -443,6 +483,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`primary-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.primary}
|
||||
@@ -466,6 +507,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`primary-foreground-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
|
||||
@@ -489,6 +531,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`border-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.border}
|
||||
@@ -512,6 +555,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`ring-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.ring}
|
||||
@@ -593,6 +637,14 @@ export function BrandingPreferencesForm({
|
||||
isDirty={hasUnsavedChanges}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={handleReset}
|
||||
resetToDefaults={
|
||||
<BrandingPreferencesResetDialog
|
||||
disabled={isResetDisabled}
|
||||
hasAdvancedBranding={hasAdvancedBranding}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={handleResetToDefaults}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -3,12 +3,17 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type FormStickySaveBarProps = {
|
||||
isDirty: boolean;
|
||||
isSubmitting: boolean;
|
||||
onReset: () => void;
|
||||
/**
|
||||
* Slot for a "reset to defaults" action, rendered after the Save button. Only shown
|
||||
* while the form is unchanged so it never competes with the Undo/unsaved-changes UI.
|
||||
*/
|
||||
resetToDefaults?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,7 +29,7 @@ export type FormStickySaveBarProps = {
|
||||
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
|
||||
* the pill chrome.
|
||||
*/
|
||||
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
|
||||
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -109,6 +114,8 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormSticky
|
||||
<Button type="submit" className="shrink-0" size="sm" loading={isSubmitting} disabled={!isDirty}>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
|
||||
{!isDirty && resetToDefaults}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { CopyIcon } from 'lucide-react';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
@@ -41,8 +40,8 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('-mx-2 flex items-center gap-2.5 rounded-md px-2 py-1.5', {
|
||||
'group cursor-pointer hover:bg-muted': signingToken,
|
||||
className={cn('my-1 flex items-center gap-2', {
|
||||
'cursor-pointer hover:underline': signingToken,
|
||||
})}
|
||||
role={signingToken ? 'button' : undefined}
|
||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
||||
@@ -53,19 +52,15 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
className="h-8 w-8 shrink-0 border-0 text-xs"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="truncate text-muted-foreground text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
||||
>
|
||||
<p>{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
|
||||
{signingToken && (
|
||||
<CopyIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
const ZIndexes: { [key: string]: string } = {
|
||||
@@ -15,10 +14,9 @@ export type StackAvatarProps = {
|
||||
zIndex?: string;
|
||||
fallbackText?: string;
|
||||
type: RecipientStatusType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
let classes = '';
|
||||
let zIndexClass = '';
|
||||
const firstClass = first ? '' : '-ml-3';
|
||||
@@ -48,14 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className={cn(
|
||||
zIndexClass,
|
||||
firstClass,
|
||||
'h-10 w-10 border-2 border-white border-solid dark:border-border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
|
||||
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-onl
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -59,90 +58,94 @@ export const StackAvatarsWithTooltip = ({
|
||||
];
|
||||
}, [recipients]);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'completed',
|
||||
label: <Trans>Completed</Trans>,
|
||||
dotClassName: 'bg-green-500',
|
||||
labelClassName: 'text-green-600 dark:text-green-400',
|
||||
recipients: completedRecipients,
|
||||
isInteractive: false,
|
||||
},
|
||||
{
|
||||
key: 'rejected',
|
||||
label: <Trans>Rejected</Trans>,
|
||||
dotClassName: 'bg-red-500',
|
||||
labelClassName: 'text-red-600 dark:text-red-400',
|
||||
recipients: rejectedRecipients,
|
||||
isInteractive: false,
|
||||
},
|
||||
{
|
||||
key: 'waiting',
|
||||
label: <Trans>Waiting</Trans>,
|
||||
dotClassName: 'bg-blue-500',
|
||||
labelClassName: 'text-blue-600 dark:text-blue-400',
|
||||
recipients: waitingRecipients,
|
||||
isInteractive: true,
|
||||
},
|
||||
{
|
||||
key: 'opened',
|
||||
label: <Trans>Opened</Trans>,
|
||||
dotClassName: 'bg-amber-400',
|
||||
labelClassName: 'text-amber-600 dark:text-amber-400',
|
||||
recipients: openedRecipients,
|
||||
isInteractive: true,
|
||||
},
|
||||
{
|
||||
key: 'uncompleted',
|
||||
label: <Trans>Uncompleted</Trans>,
|
||||
dotClassName: 'bg-gray-400',
|
||||
labelClassName: 'text-muted-foreground',
|
||||
recipients: uncompletedRecipients,
|
||||
isInteractive: true,
|
||||
},
|
||||
].filter((section) => section.recipients.length > 0);
|
||||
|
||||
return (
|
||||
<PopoverHover
|
||||
trigger={children || <StackAvatars recipients={sortedRecipients} />}
|
||||
contentProps={{
|
||||
className: 'w-64 divide-y divide-border/50 overflow-hidden p-0 text-sm',
|
||||
className: 'flex flex-col gap-y-5 py-2',
|
||||
side: position,
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<div key={section.key} className="px-3 pt-2.5 pb-1">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', section.dotClassName)} />
|
||||
<span className={section.labelClassName}>{section.label}</span>
|
||||
<span className="text-muted-foreground">{section.recipients.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5">
|
||||
{section.isInteractive
|
||||
? section.recipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))
|
||||
: section.recipients.map((recipient) => (
|
||||
<div key={recipient.id} className="-mx-2 flex items-center gap-2.5 rounded-md px-2 py-1.5">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
className="h-8 w-8 shrink-0 border-0 text-xs"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">{recipient.email || recipient.name}</p>
|
||||
<p className="truncate text-muted-foreground text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Completed</Trans>
|
||||
</h1>
|
||||
{completedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{rejectedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Waiting</Trans>
|
||||
</h1>
|
||||
{waitingRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Opened</Trans>
|
||||
</h1>
|
||||
{openedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Uncompleted</Trans>
|
||||
</h1>
|
||||
{uncompletedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverHover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
reminderSettings: reminderSettings ?? undefined,
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function TeamsSettingsPage() {
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
}),
|
||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
||||
delegateDocumentOwnership,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@react-router/node": "^7.12.0",
|
||||
"@react-router/serve": "^7.12.0",
|
||||
"@react-router/node": "^7.18.1",
|
||||
"@react-router/serve": "^7.18.1",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
@@ -81,8 +81,8 @@
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
|
||||
"@lingui/vite-plugin": "^5.6.0",
|
||||
"@react-router/dev": "^7.12.0",
|
||||
"@react-router/remix-routes-option-adapter": "^7.12.0",
|
||||
"@react-router/dev": "^7.18.1",
|
||||
"@react-router/remix-routes-option-adapter": "^7.18.1",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.9",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
|
||||
Generated
+2048
-1264
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user