feat: add license integration (#2346)

Changes:
- Adds integration for the license server.
- Prevent adding flags that the instance is not allowed to add
This commit is contained in:
David Nguyen 2026-01-29 13:30:48 +11:00 committed by GitHub
parent d18dcb4d60
commit 1b0df2d082
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1645 additions and 93 deletions

View file

@ -1,3 +1,6 @@
# The license key to enable enterprise features for self hosters
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=
# [[AUTH]]
NEXTAUTH_SECRET="secret"

4
.gitignore vendored
View file

@ -63,3 +63,7 @@ CLAUDE.md
# scripts
scripts/output*
# license
.documenso-license.json
.documenso-license-backup.json

View file

@ -7,20 +7,51 @@ import { Callout } from 'nextra/components';
# Enterprise Edition
The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance. Everything in the EE folder and all features listed [here](https://github.com/documenso/documenso/blob/main/packages/ee/FEATURES) can be used after acquiring a paid license.
## Includes
- Self-Host Documenso in any context.
- Premium Support via Slack, Discord and Email.
- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed).
- Access to all Enterprise-grade compliance and administration features.
## Limitations
The Enterprise Edition currently has no limitations except custom contract terms.
<Callout type="info">
The Enterprise Edition requires a paid subscription. [Contact us for a
quote](https://documen.so/enterprise).
</Callout>
The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance.
The following features are included in the Enterprise Edition:
{/* Keep this synced with the packages/ee/FEATURES file */}
- The Stripe Billing Module
- Organisation Authentication Portal
- Document Action Reauthentication (Passkeys and 2FA)
- 21 CFR
- Email domains
- Embed authoring
- Embed authoring white label
In addition, you will receive:
- Premium Support via Slack, Discord and Email.
- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed).
- Access to Enterprise-grade compliance and administration features.
- Permission to self-Host Documenso in any context.
The Enterprise Edition currently has no limitations except custom contract terms.
## Getting a License
To acquire an Enterprise Edition license, please [contact our sales team](https://documen.so/enterprise) for a quote. Our team will work with you to understand your requirements and provide a license that fits your needs.
## Using Your License
Once you have acquired an Enterprise Edition license:
1. Access your license key at [license.documenso.com](https://license.documenso.com)
2. Set the `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` environment variable in your Documenso instance with your license key
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY="your-license-key-here"
```
3. You can verify your license status in the Admin Panel under the Stats section.
![Admin License Status](/images/admin-license-status.webp)
Your license will be verified on startup and periodically to ensure continued access to Enterprise features.

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View file

@ -3,6 +3,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
import { trpc } from '@documenso/trpc/react';
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
export const ClaimCreateDialog = () => {
type ClaimCreateDialogProps = {
licenseFlags?: TLicenseClaim;
};
export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => {
...generateDefaultSubscriptionClaim(),
}}
onFormSubmit={createClaim}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button

View file

@ -2,6 +2,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
import { Button } from '@documenso/ui/primitives/button';
@ -21,9 +22,10 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type ClaimUpdateDialogProps = {
claim: TFindSubscriptionClaimsResponse['data'][number];
trigger: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@ -69,6 +71,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
data,
})
}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button

View file

@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = {
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const SubscriptionClaimForm = ({
subscriptionClaim,
onFormSubmit,
formSubmitTrigger,
licenseFlags,
}: SubscriptionClaimFormProps) => {
const { t } = useLingui();
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
);
const form = useForm<SubscriptionClaimFormValues>({
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
defaultValues: {
@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
</FormLabel>
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(
({ key, label, isEnterprise }) => {
const isRestrictedFeature =
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
return (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`flag-${key}`}
>
{label}
{isRestrictedFeature && ' ¹'}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
);
},
)}
</div>
{hasRestrictedEnterpriseFeatures && (
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<span>¹&nbsp;</span>
<Trans>Your current license does not include these features.</Trans>{' '}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="text-foreground underline hover:opacity-80"
>
<Trans>Learn more</Trans>
</Link>
</AlertDescription>
</Alert>
)}
</div>
{formSubmitTrigger}

View file

@ -0,0 +1,212 @@
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRightIcon,
CheckCircle2Icon,
KeyRoundIcon,
Loader2Icon,
RefreshCwIcon,
XCircleIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { CardMetric } from './metric-card';
type AdminLicenseCardProps = {
licenseData: TCachedLicense | null;
};
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const { t, i18n } = useLingui();
const { license } = licenseData || {};
if (!license) {
return (
<div className="relative">
<div className="absolute right-3 top-3 z-10">
<AdminLicenseResyncButton />
</div>
<CardMetric icon={KeyRoundIcon} title={t`License`} className="h-fit max-h-fit">
<div className="mt-1 flex items-center justify-center gap-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50">
<KeyRoundIcon className="h-5 w-5 text-muted-foreground/50" />
</div>
<div className="flex flex-col gap-0.5">
{licenseData?.requestedLicenseKey ? (
<>
<p className="text-sm font-medium text-destructive">
<Trans>Invalid License Key</Trans>
</p>
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
</>
) : (
<p className="text-sm font-medium text-muted-foreground">
<Trans>No License Configured</Trans>
</p>
)}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="flex flex-row items-center text-xs text-muted-foreground hover:text-muted-foreground/80"
>
<Trans>Learn more</Trans> <ArrowRightIcon className="h-3 w-3" />
</Link>
</div>
</div>
</CardMetric>
</div>
);
}
const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled);
return (
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pb-6 pt-4 shadow shadow-transparent duration-200 hover:shadow-border/80">
<div className="absolute right-3 top-3">
<AdminLicenseResyncButton />
</div>
<div className="flex items-start gap-2">
<div className="h-4 w-4">
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
<Trans>Documenso License</Trans>
</h3>
{match(license.status)
.with('ACTIVE', () => (
<Badge variant="default" size="small">
<CheckCircle2Icon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Active</Trans>
</Badge>
))
.with('PAST_DUE', () => (
<Badge variant="warning" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Past Due</Trans>
</Badge>
))
.with('EXPIRED', () => (
<Badge variant="destructive" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Expired</Trans>
</Badge>
))
.otherwise(() => null)}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.name}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Expires</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{i18n.date(license.periodEnd, DateTime.DATE_MED)}
</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License Key</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.licenseKey}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Features</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{enabledFlags.length > 0 ? (
enabledFlags
.map(
([flag]) =>
SUBSCRIPTION_CLAIM_FEATURE_FLAGS[
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
flag as keyof typeof SUBSCRIPTION_CLAIM_FEATURE_FLAGS
]?.label || flag,
)
.join(', ')
) : (
<Trans>No features enabled</Trans>
)}
</p>
</div>
</div>
</div>
);
};
const AdminLicenseResyncButton = () => {
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutate: resyncLicense, isPending: isResyncingLicense } =
trpc.admin.license.resync.useMutation({
onSuccess: async () => {
toast({
title: t`License synced`,
});
await revalidate();
},
onError: () => {
toast({
title: t`Failed to sync license`,
variant: 'destructive',
});
},
});
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
disabled={isResyncingLicense}
onClick={() => resyncLicense()}
>
{isResyncingLicense ? (
<Loader2Icon className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans>Sync license from server</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View file

@ -0,0 +1,78 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminLicenseStatusBannerProps = {
license: TCachedLicense | null;
};
export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => {
const licenseStatus = license?.derivedStatus;
if (!license || licenseStatus === 'ACTIVE') {
return null;
}
return (
<div
className={cn('mb-8 rounded-lg bg-yellow-200 text-yellow-900 dark:bg-yellow-400', {
'bg-destructive text-destructive-foreground':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
>
<div className="flex items-center justify-between gap-x-4 px-4 py-3 text-sm font-medium">
<div className="flex items-center">
<AlertTriangleIcon className="mr-2.5 h-5 w-5" />
{match(licenseStatus)
.with('PAST_DUE', () => (
<Trans>
License Payment Overdue - Please update your payment to avoid service disruptions.
</Trans>
))
.with('EXPIRED', () => (
<Trans>
License Expired - Please renew your license to continue using enterprise features.
</Trans>
))
.with('UNAUTHORIZED', () =>
license ? (
<Trans>
Invalid License Type - Your Documenso instance is using features that are not part
of your license.
</Trans>
) : (
<Trans>
Missing License - Your Documenso instance is using features that require a
license.
</Trans>
),
)
.otherwise(() => null)}
</div>
<Button
variant="outline"
size="sm"
className={cn({
'border-yellow-900/30 text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
licenseStatus === 'PAST_DUE',
'border-destructive-foreground/30 text-destructive-foreground hover:bg-destructive/80':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
asChild
>
<Link to="https://docs.documenso.com/users/licenses/enterprise-edition" target="_blank">
<KeyRoundIcon className="mr-1.5 h-4 w-4" />
<Trans>See Documentation</Trans>
</Link>
</Button>
</div>
</div>
);
};

View file

@ -5,15 +5,16 @@ import { cn } from '@documenso/ui/lib/utils';
export type CardMetricProps = {
icon?: LucideIcon;
title: string;
value: string | number;
value?: string | number;
className?: string;
children?: React.ReactNode;
};
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
return (
<div
className={cn(
'border-border bg-background hover:shadow-border/80 h-32 max-h-32 max-w-full overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
'h-32 max-h-32 max-w-full overflow-hidden rounded-lg border border-border bg-background shadow shadow-transparent duration-200 hover:shadow-border/80',
className,
)}
>
@ -21,7 +22,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
<div className="flex items-start">
{Icon && (
<div className="mr-2 h-4 w-4">
<Icon className="text-muted-foreground h-4 w-4" />
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
)}
@ -30,9 +31,11 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
</h3>
</div>
<p className="text-foreground mt-auto text-4xl font-semibold leading-8">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
{children || (
<p className="mt-auto text-4xl font-semibold leading-8 text-foreground">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
</div>
</div>
);

View file

@ -6,6 +6,7 @@ import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
@ -27,7 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog';
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
export const AdminClaimsTable = () => {
type AdminClaimsTableProps = {
licenseFlags?: TLicenseClaim;
};
export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => {
const { t } = useLingui();
const { toast } = useToast();
@ -97,11 +102,11 @@ export const AdminClaimsTable = () => {
);
if (flags.length === 0) {
return <p className="text-muted-foreground text-xs">{t`None`}</p>;
return <p className="text-xs text-muted-foreground">{t`None`}</p>;
}
return (
<ul className="text-muted-foreground list-disc space-y-1 text-xs">
<ul className="list-disc space-y-1 text-xs text-muted-foreground">
{flags.map(({ key, label }) => (
<li key={key}>{label}</li>
))}
@ -114,7 +119,7 @@ export const AdminClaimsTable = () => {
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
@ -124,6 +129,7 @@ export const AdminClaimsTable = () => {
<ClaimUpdateDialog
claim={row.original}
licenseFlags={licenseFlags}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>

View file

@ -7,7 +7,6 @@ import {
data,
isRouteErrorResponse,
useLoaderData,
useLocation,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
@ -87,8 +86,6 @@ export async function loader({ request }: Route.LoaderArgs) {
export function Layout({ children }: { children: React.ReactNode }) {
const { theme } = useLoaderData<typeof loader>() || {};
const location = useLocation();
return (
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
<LayoutContent>{children}</LayoutContent>
@ -129,6 +126,18 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<script>0</script>
</head>
<body>
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
{/* {licenseStatus === '?' && (
<div className="bg-destructive text-destructive-foreground">
<div className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium">
<div className="flex items-center">
<AlertTriangleIcon className="mr-2 h-4 w-4" />
<Trans>This is an expired license instance of Documenso</Trans>
</div>
</div>
</div>
)} */}
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>

View file

@ -11,25 +11,37 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import type { Route } from './+types/_layout';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
if (!user || !isAdmin(user)) {
throw redirect('/');
}
return {
license: license || null,
};
}
export default function AdminLayout() {
export default function AdminLayout({ loaderData }: Route.ComponentProps) {
const { license } = loaderData;
const { pathname } = useLocation();
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<AdminLicenseStatusBanner license={license} />
<h1 className="text-4xl font-semibold">
<Trans>Admin Panel</Trans>
</h1>

View file

@ -4,13 +4,26 @@ import { useLingui } from '@lingui/react/macro';
import { useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { Input } from '@documenso/ui/primitives/input';
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminClaimsTable } from '~/components/tables/admin-claims-table';
export default function Claims() {
import type { Route } from './+types/claims';
export async function loader() {
const licenseData = await LicenseClient.getInstance()?.getCachedLicense();
return {
licenseFlags: licenseData?.license?.flags,
};
}
export default function Claims({ loaderData }: Route.ComponentProps) {
const { licenseFlags } = loaderData;
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
@ -47,7 +60,7 @@ export default function Claims() {
subtitle={t`Manage all subscription claims`}
hideDivider
>
<ClaimCreateDialog />
<ClaimCreateDialog licenseFlags={licenseFlags} />
</SettingsHeader>
<div className="mt-4">
@ -58,7 +71,7 @@ export default function Claims() {
className="mb-4"
/>
<AdminClaimsTable />
<AdminClaimsTable licenseFlags={licenseFlags} />
</div>
</div>
);

View file

@ -12,6 +12,8 @@ import type { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
import { AppError } from '@documenso/lib/errors/app-error';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
@ -40,7 +42,20 @@ import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/organisations.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
export async function loader() {
const licenseData = await LicenseClient.getInstance()?.getCachedLicense();
return {
licenseFlags: licenseData?.license?.flags,
};
}
export default function OrganisationGroupSettingsPage({
params,
loaderData,
}: Route.ComponentProps) {
const { licenseFlags } = loaderData;
const { t } = useLingui();
const { toast } = useToast();
@ -129,7 +144,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
if (isLoadingOrganisation) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@ -239,7 +254,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
)}
</Alert>
<OrganisationAdminForm organisation={organisation} />
<OrganisationAdminForm organisation={organisation} licenseFlags={licenseFlags} />
<div className="mt-16 space-y-10">
<div>
@ -278,6 +293,7 @@ type TUpdateGenericOrganisationDataFormSchema = z.infer<
type OrganisationAdminFormOptions = {
organisation: TGetAdminOrganisationResponse;
licenseFlags?: TLicenseClaim;
};
const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
@ -349,7 +365,7 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt
<Input {...field} />
</FormControl>
{!form.formState.errors.url && (
<span className="text-foreground/50 text-xs font-normal">
<span className="text-xs font-normal text-foreground/50">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}`
) : (
@ -381,12 +397,17 @@ const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSche
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdminFormOptions) => {
const { toast } = useToast();
const { t } = useLingui();
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
);
const form = useForm<TUpdateOrganisationBillingFormSchema>({
resolver: zodResolver(ZUpdateOrganisationBillingFormSchema),
defaultValues: {
@ -440,7 +461,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Inherited subscription claim</Trans>
@ -493,7 +514,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${field.value}`}
className="text-foreground/50 text-xs font-normal"
className="text-xs font-normal text-foreground/50"
>
{`https://dashboard.stripe.com/customers/${field.value}`}
</Link>
@ -582,34 +603,57 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
</FormLabel>
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
<FormField
key={key}
control={form.control}
name={`claims.flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
const isRestrictedFeature =
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
return (
<FormField
key={key}
control={form.control}
name={`claims.flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`flag-${key}`}
>
{label}
{isRestrictedFeature && ' ¹'}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
);
})}
</div>
{hasRestrictedEnterpriseFeatures && (
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<span>¹&nbsp;</span>
<Trans>Your current license does not include these features.</Trans>{' '}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="text-foreground underline hover:opacity-80"
>
<Trans>Learn more</Trans>
</Link>
</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-end">

View file

@ -23,8 +23,10 @@ import {
getUserWithSignedDocumentMonthlyGrowth,
getUsersCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
import { AdminLicenseCard } from '~/components/general/admin-license-card';
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
@ -42,6 +44,7 @@ export async function loader() {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
licenseData,
] = await Promise.all([
getUsersCount(),
getOrganisationsWithSubscriptionsCount(),
@ -50,6 +53,7 @@ export async function loader() {
getSignerConversionMonthly(),
getUserWithSignedDocumentMonthlyGrowth(),
getMonthlyActiveUsers(),
LicenseClient.getInstance()?.getCachedLicense(),
]);
return {
@ -60,6 +64,7 @@ export async function loader() {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
licenseData: licenseData || null,
};
}
@ -74,6 +79,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
licenseData,
} = loaderData;
return (
@ -94,6 +100,10 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
</div>
<div className="mb-8 mt-4">
<AdminLicenseCard licenseData={licenseData} />
</div>
<div className="mt-16 gap-8">
<div>
<h3 className="text-3xl font-semibold">

View file

@ -10,6 +10,7 @@ import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { env } from '@documenso/lib/utils/env';
@ -140,4 +141,7 @@ if (env('NODE_ENV') !== 'development') {
void TelemetryClient.start();
}
// Start license client to verify license on startup.
void LicenseClient.start();
export default app;

View file

@ -0,0 +1,326 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const LICENSE_FILE_NAME = '.documenso-license.json';
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
/**
* Get the path to the license file.
*
* The server reads from process.cwd() which is apps/remix when the dev server runs.
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
*/
const getLicenseFilePath = () => {
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
};
/**
* Get the path to the backup license file.
*/
const getBackupLicenseFilePath = () => {
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
};
/**
* Backup the existing license file if it exists.
*/
const backupLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(licensePath);
await fs.rename(licensePath, backupPath);
} catch (e) {
// File doesn't exist, nothing to backup
console.log(e);
}
};
/**
* Restore the backup license file if it exists.
*/
const restoreLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(backupPath);
await fs.rename(backupPath, licensePath);
} catch (e) {
// Backup doesn't exist, nothing to restore
console.log(e);
}
};
/**
* Write a license file with the given data.
* Pass null to delete the license file.
*/
const writeLicenseFile = async (data: TCachedLicense | null) => {
const licensePath = getLicenseFilePath();
if (data === null) {
await fs.unlink(licensePath).catch(() => {
// File doesn't exist, ignore
});
} else {
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
}
};
/**
* Create a mock license object with the given flags.
*/
const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: {
status: 'ACTIVE',
createdAt: new Date(),
name: 'Test License',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
cancelAtPeriodEnd: false,
licenseKey: 'test-license-key',
flags,
},
requestedLicenseKey: 'test-license-key',
derivedStatus: 'ACTIVE',
unauthorizedFlagUsage: false,
};
};
// Run tests serially to avoid race conditions with the license file
test.describe.configure({ mode: 'serial' });
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
test.describe.skip('Enterprise Feature Restrictions', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({
page,
}) => {
// Ensure no license file exists
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Check that enterprise features have asterisks (are restricted)
// These are the enterprise features that should be marked with *
await expect(page.getByText(/Email domains\s¹/)).toBeVisible();
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
// Check that the alert is visible
await expect(
page.getByText('Your current license does not include these features.'),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible();
// Check that enterprise feature checkboxes are disabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeDisabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeDisabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
});
test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({
page,
}) => {
// Create a license with ALL enterprise features enabled
await writeLicenseFile(
createMockLicenseWithFlags({
emailDomains: true,
embedAuthoring: true,
embedAuthoringWhiteLabel: true,
cfr21: true,
authenticationPortal: true,
billing: true,
}),
);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Check that enterprise features do NOT have asterisks
// They should show without the * since the license covers them
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible();
// The plain labels should be visible (without asterisks)
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
// The alert should NOT be visible
await expect(
page.getByText('Your current license does not include these features.'),
).not.toBeVisible();
// Check that enterprise feature checkboxes are enabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeEnabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeEnabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeEnabled();
});
test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({
page,
}) => {
// Create a license with SOME enterprise features (emailDomains and cfr21)
await writeLicenseFile(
createMockLicenseWithFlags({
emailDomains: true,
cfr21: true,
// embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included
}),
);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Features NOT in license should have asterisks
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
// Features IN license should NOT have asterisks
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
// The plain labels for licensed features should be visible
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
// Alert should be visible since some features are restricted
await expect(
page.getByText('Your current license does not include these features.'),
).toBeVisible();
// Licensed features should be enabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeEnabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeEnabled();
// Unlicensed features should be disabled
const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring');
await expect(embedAuthoringCheckbox).toBeDisabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
});
test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => {
// Ensure no license file exists
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Non-enterprise features should NOT have asterisks
await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible();
await expect(page.getByText(/Branding\s¹/)).not.toBeVisible();
await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible();
// Non-enterprise features should always be enabled
const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments');
await expect(unlimitedDocsCheckbox).toBeEnabled();
const brandingCheckbox = page.locator('#flag-allowCustomBranding');
await expect(brandingCheckbox).toBeEnabled();
const embedSigningCheckbox = page.locator('#flag-embedSigning');
await expect(embedSigningCheckbox).toBeEnabled();
});
});

View file

@ -0,0 +1,392 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const LICENSE_FILE_NAME = '.documenso-license.json';
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
/**
* Get the path to the license file.
*
* The server reads from process.cwd() which is apps/remix when the dev server runs.
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
*/
const getLicenseFilePath = () => {
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
};
/**
* Get the path to the backup license file.
*/
const getBackupLicenseFilePath = () => {
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
};
/**
* Backup the existing license file if it exists.
*/
const backupLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(licensePath);
await fs.rename(licensePath, backupPath);
} catch (e) {
// File doesn't exist, nothing to backup
console.log(e);
}
};
/**
* Restore the backup license file if it exists.
*/
const restoreLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(backupPath);
await fs.rename(backupPath, licensePath);
} catch (e) {
// Backup doesn't exist, nothing to restore
console.log(e);
}
};
/**
* Write a license file with the given data.
* Pass null to delete the license file.
*/
const writeLicenseFile = async (data: TCachedLicense | null) => {
const licensePath = getLicenseFilePath();
if (data === null) {
await fs.unlink(licensePath).catch(() => {
// File doesn't exist, ignore
});
} else {
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
}
};
/**
* Create a mock license object with the given status and unauthorized flag.
*/
const createMockLicense = (
status: 'ACTIVE' | 'EXPIRED' | 'PAST_DUE',
unauthorizedFlagUsage: boolean,
): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: {
status,
createdAt: new Date(),
name: 'Test License',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
cancelAtPeriodEnd: false,
licenseKey: 'test-license-key',
flags: {},
},
requestedLicenseKey: 'test-license-key',
derivedStatus: unauthorizedFlagUsage ? 'UNAUTHORIZED' : status,
unauthorizedFlagUsage,
};
};
/**
* Create a mock license object with no license data (only unauthorized flag).
*/
const createMockUnauthorizedWithoutLicense = (): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: null,
unauthorizedFlagUsage: true,
derivedStatus: 'UNAUTHORIZED',
};
};
// Run tests serially to avoid race conditions with the license file
test.describe.configure({ mode: 'serial' });
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
test.describe.skip('License Status Banner', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
test('[ADMIN]: no banner when license file is missing', async ({ page }) => {
// Ensure no license file exists BEFORE any page loads
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should not be visible (no license file)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner messages should not be visible (no license file means no banner)
await expect(page.getByText('License payment overdue')).not.toBeVisible();
await expect(page.getByText('License expired')).not.toBeVisible();
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
await expect(page.getByText('Missing License')).not.toBeVisible();
});
test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => {
// Create an ACTIVE license BEFORE any page loads
await writeLicenseFile(createMockLicense('ACTIVE', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should not be visible (license is ACTIVE)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner messages should not be visible (license is ACTIVE)
await expect(page.getByText('License payment overdue')).not.toBeVisible();
await expect(page.getByText('License expired')).not.toBeVisible();
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
});
test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => {
// Create a PAST_DUE license BEFORE any page loads
await writeLicenseFile(createMockLicense('PAST_DUE', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (only shows for EXPIRED + unauthorized)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show PAST_DUE message
await expect(page.getByText('License payment overdue')).toBeVisible();
await expect(
page.getByText('Please update your payment to avoid service disruptions.'),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => {
// Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (requires BOTH expired AND unauthorized)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show EXPIRED message
await expect(page.getByText('License expired')).toBeVisible();
await expect(
page.getByText('Please renew your license to continue using enterprise features.'),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({
page,
}) => {
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', true));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner SHOULD be visible (EXPIRED + unauthorized)
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
// Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED)
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
});
test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({
page,
}) => {
// Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads
await writeLicenseFile(createMockLicense('ACTIVE', true));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (requires EXPIRED status)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show UNAUTHORIZED message
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({
page,
}) => {
// Create a license file with unauthorized flag but no license data BEFORE any page loads
// Note: Even without license data, the banner shows "Invalid License Type" because the
// license file exists (just with license: null). The "Missing License" message would only
// show if the entire license prop was null, which doesn't happen with a valid file.
await writeLicenseFile(createMockUnauthorizedWithoutLicense());
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (no EXPIRED status, only unauthorized flag)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show Invalid License Type message (unauthorized flag is set)
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({
page,
}) => {
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', true));
const { user } = await seedUser();
// Navigate to documents page - license is read during page load
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
// Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized)
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
});
});

View file

@ -83,10 +83,21 @@ export default defineConfig({
testMatch: /e2e\/api\/.*\.spec\.ts/,
workers: 10, // Limited by DB connections before it gets flakey.
},
// Run UI Tests
// License tests that share a single license file - must run serially
{
name: 'license',
testMatch: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },
},
workers: 1, // Must run serially since they share a license file
},
// Run UI Tests (excluding license tests which have their own project)
{
name: 'ui',
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
testIgnore: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },

View file

@ -2,6 +2,7 @@ This file lists all features currently licensed under the Documenso Enterprise E
Copyright (c) 2023 Documenso, Inc
- The Stripe Billing Module
- Organisation Authentication Portal
- Document Action Reauthentication (Passkeys and 2FA)
- 21 CFR
- Email domains

View file

@ -0,0 +1,229 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import type { TLicenseClaim } from '../../types/license';
import {
LICENSE_FILE_NAME,
type TCachedLicense,
type TLicenseResponse,
ZCachedLicenseSchema,
ZLicenseResponseSchema,
} from '../../types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '../../types/subscription';
import { env } from '../../utils/env';
const LICENSE_KEY = env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY');
const LICENSE_SERVER_URL =
env('INTERNAL_OVERRIDE_LICENSE_SERVER_URL') || 'https://license.documenso.com';
export class LicenseClient {
private static instance: LicenseClient | null = null;
/**
* We cache the license in memory incase there is permission issues with
* retrieving the license from the local file system.
*/
private cachedLicense: TCachedLicense | null = null;
private constructor() {}
/**
* Start the license client.
*
* This will ping the license server with the configured license key and store
* the response locally in a JSON file.
*/
public static async start(): Promise<void> {
if (LicenseClient.instance) {
return;
}
const instance = new LicenseClient();
LicenseClient.instance = instance;
try {
await instance.initialize();
} catch (err) {
// Do nothing.
console.error('[License] Failed to verify license:', err);
}
}
/**
* Get the current license client instance.
*/
public static getInstance(): LicenseClient | null {
return LicenseClient.instance;
}
public async getCachedLicense(): Promise<TCachedLicense | null> {
if (this.cachedLicense) {
return this.cachedLicense;
}
const localLicenseFile = await this.loadFromFile();
return localLicenseFile;
}
/**
* Force resync the license from the license server.
*
* This will re-ping the license server and update the cached license file.
*/
public async resync(): Promise<void> {
await this.initialize();
}
private async initialize(): Promise<void> {
console.log('[License] Checking license with server...');
const cachedLicense = await this.loadFromFile();
if (cachedLicense) {
this.cachedLicense = cachedLicense;
}
const response = await this.pingLicenseServer();
// If server is not responding, or erroring, use the cached license.
if (!response) {
console.warn('[License] License server not responding, using cached license.');
return;
}
const allowedFlags = response?.data?.flags || {};
// Check for unauthorized flag usage
const unauthorizedFlagUsage = await this.checkUnauthorizedFlagUsage(allowedFlags);
if (unauthorizedFlagUsage) {
console.warn('[License] Found unauthorized flag usage.');
}
let status: TCachedLicense['derivedStatus'] = 'NOT_FOUND';
if (response?.data?.status) {
status = response.data.status;
}
if (unauthorizedFlagUsage) {
status = 'UNAUTHORIZED';
}
const data: TCachedLicense = {
lastChecked: new Date().toISOString(),
license: response?.data || null,
requestedLicenseKey: LICENSE_KEY,
unauthorizedFlagUsage,
derivedStatus: status,
};
this.cachedLicense = data;
await this.saveToFile(data);
console.log('[License] License check completed successfully.');
}
/**
* Ping the license server to get the license response.
*
* If license not found returns null.
*/
private async pingLicenseServer(): Promise<TLicenseResponse | null> {
if (!LICENSE_KEY) {
return null;
}
const endpoint = new URL('api/license', LICENSE_SERVER_URL).toString();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ license: LICENSE_KEY }),
});
if (!response.ok) {
throw new Error(`License server returned ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return ZLicenseResponseSchema.parse(data);
}
private async saveToFile(data: TCachedLicense): Promise<void> {
const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME);
try {
await fs.writeFile(licenseFilePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (error) {
console.error('[License] Failed to save license file:', error);
}
}
private async loadFromFile(): Promise<TCachedLicense | null> {
const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME);
try {
const fileContents = await fs.readFile(licenseFilePath, 'utf-8');
return ZCachedLicenseSchema.parse(JSON.parse(fileContents));
} catch {
return null;
}
}
/**
* Check if any organisation claims are using flags that are not permitted by the current license.
*/
private async checkUnauthorizedFlagUsage(licenseFlags: Partial<TLicenseClaim>): Promise<boolean> {
// Get flags that are NOT permitted by the license by subtracting the allowed flags from the license flags.
const disallowedFlags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags[flag.key as keyof TLicenseClaim],
);
let unauthorizedFlagUsage = false;
if (IS_BILLING_ENABLED() && !licenseFlags.billing) {
unauthorizedFlagUsage = true;
}
try {
const organisationWithUnauthorizedFlags = await prisma.organisationClaim.findFirst({
where: {
OR: disallowedFlags.map((flag) => ({
flags: {
path: [flag.key],
equals: true,
},
})),
},
select: {
id: true,
organisation: {
select: {
id: true,
},
},
flags: true,
},
});
if (organisationWithUnauthorizedFlags) {
unauthorizedFlagUsage = true;
}
} catch (error) {
console.error('[License] Failed to check unauthorized flag usage:', error);
}
return unauthorizedFlagUsage;
}
}

View file

@ -0,0 +1,83 @@
import { z } from 'zod';
/**
* Note: Keep this in sync with the Documenso License Server schemas.
*/
export const ZLicenseClaimSchema = z.object({
emailDomains: z.boolean().optional(),
embedAuthoring: z.boolean().optional(),
embedAuthoringWhiteLabel: z.boolean().optional(),
cfr21: z.boolean().optional(),
authenticationPortal: z.boolean().optional(),
billing: z.boolean().optional(),
});
/**
* Note: Keep this in sync with the Documenso License Server schemas.
*/
export const ZLicenseRequestSchema = z.object({
license: z.string().min(1, 'License key is required'),
});
/**
* Note: Keep this in sync with the Documenso License Server schemas.
*/
export const ZLicenseResponseSchema = z.object({
success: z.boolean(),
// Note that this is nullable, null means license was not found.
data: z
.object({
status: z.enum(['ACTIVE', 'EXPIRED', 'PAST_DUE']),
createdAt: z.coerce.date(),
name: z.string(),
periodEnd: z.coerce.date(),
cancelAtPeriodEnd: z.boolean(),
licenseKey: z.string(),
flags: ZLicenseClaimSchema,
})
.nullable(),
});
export type TLicenseClaim = z.infer<typeof ZLicenseClaimSchema>;
export type TLicenseRequest = z.infer<typeof ZLicenseRequestSchema>;
export type TLicenseResponse = z.infer<typeof ZLicenseResponseSchema>;
/**
* Schema for the cached license data stored in the file.
*/
export const ZCachedLicenseSchema = z.object({
/**
* The last time the license was synced.
*/
lastChecked: z.string(),
/**
* The raw license response from the license server.
*/
license: ZLicenseResponseSchema.shape.data,
/**
* The license key that is currently stored on the system environment variable.
*/
requestedLicenseKey: z.string().optional(),
/**
* Whether the current license has unauthorized flag usage.
*/
unauthorizedFlagUsage: z.boolean(),
/**
* The derived status of the license. This is calculated based on the license response and the unauthorized flag usage.
*/
derivedStatus: z.enum([
'UNAUTHORIZED', // Unauthorized flag usage detected, overrides everything except PAST_DUE since that's a grace period.
'ACTIVE', // License is active and everything is good.
'EXPIRED', // License is expired and there is no unauthorized flag usage.
'PAST_DUE', // License is past due.
'NOT_FOUND', // Requested license key is not found.
]),
});
export type TCachedLicense = z.infer<typeof ZCachedLicenseSchema>;
export const LICENSE_FILE_NAME = '.documenso-license.json';

View file

@ -42,6 +42,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
{
label: string;
key: keyof TClaimFlags;
isEnterprise?: boolean;
}
> = {
unlimitedDocuments: {
@ -59,10 +60,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
emailDomains: {
key: 'emailDomains',
label: 'Email domains',
isEnterprise: true,
},
embedAuthoring: {
key: 'embedAuthoring',
label: 'Embed authoring',
isEnterprise: true,
},
embedSigning: {
key: 'embedSigning',
@ -71,6 +74,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
embedAuthoringWhiteLabel: {
key: 'embedAuthoringWhiteLabel',
label: 'White label for embed authoring',
isEnterprise: true,
},
embedSigningWhiteLabel: {
key: 'embedSigningWhiteLabel',
@ -79,10 +83,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
cfr21: {
key: 'cfr21',
label: '21 CFR',
isEnterprise: true,
},
authenticationPortal: {
key: 'authenticationPortal',
label: 'Authentication portal',
isEnterprise: true,
},
allowLegacyEnvelopes: {
key: 'allowLegacyEnvelopes',

View file

@ -0,0 +1,17 @@
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { adminProcedure } from '../trpc';
import { ZResyncLicenseRequestSchema, ZResyncLicenseResponseSchema } from './resync-license.types';
export const resyncLicenseRoute = adminProcedure
.input(ZResyncLicenseRequestSchema)
.output(ZResyncLicenseResponseSchema)
.mutation(async () => {
const client = LicenseClient.getInstance();
if (!client) {
return;
}
await client.resync();
});

View file

@ -0,0 +1,8 @@
import { z } from 'zod';
export const ZResyncLicenseRequestSchema = z.void();
export const ZResyncLicenseResponseSchema = z.void();
export type TResyncLicenseRequest = z.infer<typeof ZResyncLicenseRequestSchema>;
export type TResyncLicenseResponse = z.infer<typeof ZResyncLicenseResponseSchema>;

View file

@ -17,6 +17,7 @@ import { getUserRoute } from './get-user';
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { resyncLicenseRoute } from './resync-license';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
@ -44,6 +45,9 @@ export const adminRouter = router({
stripe: {
createCustomer: createStripeCustomerRoute,
},
license: {
resync: resyncLicenseRoute,
},
user: {
get: getUserRoute,
update: updateUserRoute,

View file

@ -2,6 +2,7 @@ declare namespace NodeJS {
export interface ProcessEnv {
PORT?: string;
NEXT_PUBLIC_WEBAPP_URL?: string;
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY?: string;
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;

View file

@ -50,6 +50,7 @@
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
"NEXT_PRIVATE_LOGGER_FILE_PATH",