mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 18:04:55 +10:00
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:
@@ -1,3 +1,6 @@
|
|||||||
|
# The license key to enable enterprise features for self hosters
|
||||||
|
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=
|
||||||
|
|
||||||
# [[AUTH]]
|
# [[AUTH]]
|
||||||
NEXTAUTH_SECRET="secret"
|
NEXTAUTH_SECRET="secret"
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,7 @@ CLAUDE.md
|
|||||||
|
|
||||||
# scripts
|
# scripts
|
||||||
scripts/output*
|
scripts/output*
|
||||||
|
|
||||||
|
# license
|
||||||
|
.documenso-license.json
|
||||||
|
.documenso-license-backup.json
|
||||||
|
|||||||
@@ -7,20 +7,51 @@ import { Callout } from 'nextra/components';
|
|||||||
|
|
||||||
# Enterprise Edition
|
# 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">
|
<Callout type="info">
|
||||||
The Enterprise Edition requires a paid subscription. [Contact us for a
|
The Enterprise Edition requires a paid subscription. [Contact us for a
|
||||||
quote](https://documen.so/enterprise).
|
quote](https://documen.so/enterprise).
|
||||||
</Callout>
|
</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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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 |
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||||
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
|
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
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 type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||||
|
|
||||||
export const ClaimCreateDialog = () => {
|
type ClaimCreateDialogProps = {
|
||||||
|
licenseFlags?: TLicenseClaim;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => {
|
|||||||
...generateDefaultSubscriptionClaim(),
|
...generateDefaultSubscriptionClaim(),
|
||||||
}}
|
}}
|
||||||
onFormSubmit={createClaim}
|
onFormSubmit={createClaim}
|
||||||
|
licenseFlags={licenseFlags}
|
||||||
formSubmitTrigger={
|
formSubmitTrigger={
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -21,9 +22,10 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
|||||||
export type ClaimUpdateDialogProps = {
|
export type ClaimUpdateDialogProps = {
|
||||||
claim: TFindSubscriptionClaimsResponse['data'][number];
|
claim: TFindSubscriptionClaimsResponse['data'][number];
|
||||||
trigger: React.ReactNode;
|
trigger: React.ReactNode;
|
||||||
|
licenseFlags?: TLicenseClaim;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
|
export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateDialogProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -69,6 +71,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
|
|||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
licenseFlags={licenseFlags}
|
||||||
formSubmitTrigger={
|
formSubmitTrigger={
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { SubscriptionClaim } from '@prisma/client';
|
import type { SubscriptionClaim } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Link } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||||
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
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 { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = {
|
|||||||
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
|
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
|
||||||
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
|
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
|
||||||
formSubmitTrigger?: React.ReactNode;
|
formSubmitTrigger?: React.ReactNode;
|
||||||
|
licenseFlags?: TLicenseClaim;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubscriptionClaimForm = ({
|
export const SubscriptionClaimForm = ({
|
||||||
subscriptionClaim,
|
subscriptionClaim,
|
||||||
onFormSubmit,
|
onFormSubmit,
|
||||||
formSubmitTrigger,
|
formSubmitTrigger,
|
||||||
|
licenseFlags,
|
||||||
}: SubscriptionClaimFormProps) => {
|
}: SubscriptionClaimFormProps) => {
|
||||||
const { t } = useLingui();
|
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>({
|
const form = useForm<SubscriptionClaimFormValues>({
|
||||||
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
|
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
<div className="mt-2 space-y-2 rounded-md border p-4">
|
||||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
|
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(
|
||||||
<FormField
|
({ key, label, isEnterprise }) => {
|
||||||
key={key}
|
const isRestrictedFeature =
|
||||||
control={form.control}
|
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label
|
return (
|
||||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
<FormField
|
||||||
htmlFor={`flag-${key}`}
|
key={key}
|
||||||
>
|
control={form.control}
|
||||||
{label}
|
name={`flags.${key}`}
|
||||||
</label>
|
render={({ field }) => (
|
||||||
</div>
|
<FormItem className="flex items-center space-x-2">
|
||||||
</FormControl>
|
<FormControl>
|
||||||
</FormItem>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{hasRestrictedEnterpriseFeatures && (
|
||||||
|
<Alert variant="neutral" className="mt-4">
|
||||||
|
<AlertDescription>
|
||||||
|
<span>¹ </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>
|
||||||
|
|
||||||
{formSubmitTrigger}
|
{formSubmitTrigger}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,15 +5,16 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
export type CardMetricProps = {
|
export type CardMetricProps = {
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
value?: string | number;
|
||||||
className?: string;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -21,7 +22,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
|||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className="mr-2 h-4 w-4">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -30,9 +31,11 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-foreground mt-auto text-4xl font-semibold leading-8">
|
{children || (
|
||||||
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
<p className="mt-auto text-4xl font-semibold leading-8 text-foreground">
|
||||||
</p>
|
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
|||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
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 { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog';
|
||||||
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
|
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
|
||||||
|
|
||||||
export const AdminClaimsTable = () => {
|
type AdminClaimsTableProps = {
|
||||||
|
licenseFlags?: TLicenseClaim;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -97,11 +102,11 @@ export const AdminClaimsTable = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (flags.length === 0) {
|
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 (
|
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 }) => (
|
{flags.map(({ key, label }) => (
|
||||||
<li key={key}>{label}</li>
|
<li key={key}>{label}</li>
|
||||||
))}
|
))}
|
||||||
@@ -114,7 +119,7 @@ export const AdminClaimsTable = () => {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
@@ -124,6 +129,7 @@ export const AdminClaimsTable = () => {
|
|||||||
|
|
||||||
<ClaimUpdateDialog
|
<ClaimUpdateDialog
|
||||||
claim={row.original}
|
claim={row.original}
|
||||||
|
licenseFlags={licenseFlags}
|
||||||
trigger={
|
trigger={
|
||||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+12
-3
@@ -7,7 +7,6 @@ import {
|
|||||||
data,
|
data,
|
||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
useLoaderData,
|
useLoaderData,
|
||||||
useLocation,
|
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
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 }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const { theme } = useLoaderData<typeof loader>() || {};
|
const { theme } = useLoaderData<typeof loader>() || {};
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
||||||
<LayoutContent>{children}</LayoutContent>
|
<LayoutContent>{children}</LayoutContent>
|
||||||
@@ -129,6 +126,18 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
<script>0</script>
|
<script>0</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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}>
|
<SessionProvider initialSession={session}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<TrpcProvider>
|
<TrpcProvider>
|
||||||
|
|||||||
@@ -11,25 +11,37 @@ import {
|
|||||||
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
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 { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const { user } = await getSession(request);
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
const license = await LicenseClient.getInstance()?.getCachedLicense();
|
||||||
|
|
||||||
if (!user || !isAdmin(user)) {
|
if (!user || !isAdmin(user)) {
|
||||||
throw redirect('/');
|
throw redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
license: license || null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { license } = loaderData;
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<AdminLicenseStatusBanner license={license} />
|
||||||
|
|
||||||
<h1 className="text-4xl font-semibold">
|
<h1 className="text-4xl font-semibold">
|
||||||
<Trans>Admin Panel</Trans>
|
<Trans>Admin Panel</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -4,13 +4,26 @@ import { useLingui } from '@lingui/react/macro';
|
|||||||
import { useLocation, useSearchParams } from 'react-router';
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
|
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { AdminClaimsTable } from '~/components/tables/admin-claims-table';
|
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 { t } = useLingui();
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -47,7 +60,7 @@ export default function Claims() {
|
|||||||
subtitle={t`Manage all subscription claims`}
|
subtitle={t`Manage all subscription claims`}
|
||||||
hideDivider
|
hideDivider
|
||||||
>
|
>
|
||||||
<ClaimCreateDialog />
|
<ClaimCreateDialog licenseFlags={licenseFlags} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -58,7 +71,7 @@ export default function Claims() {
|
|||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminClaimsTable />
|
<AdminClaimsTable licenseFlags={licenseFlags} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type { z } from 'zod';
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
|
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
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 { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
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';
|
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 { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -129,7 +144,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
if (isLoadingOrganisation) {
|
if (isLoadingOrganisation) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center rounded-lg py-32">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -239,7 +254,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<OrganisationAdminForm organisation={organisation} />
|
<OrganisationAdminForm organisation={organisation} licenseFlags={licenseFlags} />
|
||||||
|
|
||||||
<div className="mt-16 space-y-10">
|
<div className="mt-16 space-y-10">
|
||||||
<div>
|
<div>
|
||||||
@@ -278,6 +293,7 @@ type TUpdateGenericOrganisationDataFormSchema = z.infer<
|
|||||||
|
|
||||||
type OrganisationAdminFormOptions = {
|
type OrganisationAdminFormOptions = {
|
||||||
organisation: TGetAdminOrganisationResponse;
|
organisation: TGetAdminOrganisationResponse;
|
||||||
|
licenseFlags?: TLicenseClaim;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
|
const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
|
||||||
@@ -349,7 +365,7 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt
|
|||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{!form.formState.errors.url && (
|
{!form.formState.errors.url && (
|
||||||
<span className="text-foreground/50 text-xs font-normal">
|
<span className="text-xs font-normal text-foreground/50">
|
||||||
{field.value ? (
|
{field.value ? (
|
||||||
`${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}`
|
`${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}`
|
||||||
) : (
|
) : (
|
||||||
@@ -381,12 +397,17 @@ const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSche
|
|||||||
|
|
||||||
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
|
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
|
||||||
|
|
||||||
const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
|
const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdminFormOptions) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
|
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>({
|
const form = useForm<TUpdateOrganisationBillingFormSchema>({
|
||||||
resolver: zodResolver(ZUpdateOrganisationBillingFormSchema),
|
resolver: zodResolver(ZUpdateOrganisationBillingFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -440,7 +461,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
|||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</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>
|
<h2>
|
||||||
<strong>
|
<strong>
|
||||||
<Trans>Inherited subscription claim</Trans>
|
<Trans>Inherited subscription claim</Trans>
|
||||||
@@ -493,7 +514,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
|||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
to={`https://dashboard.stripe.com/customers/${field.value}`}
|
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}`}
|
{`https://dashboard.stripe.com/customers/${field.value}`}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -582,34 +603,57 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
<div className="mt-2 space-y-2 rounded-md border p-4">
|
||||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
|
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
||||||
<FormField
|
const isRestrictedFeature =
|
||||||
key={key}
|
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label
|
return (
|
||||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
<FormField
|
||||||
htmlFor={`flag-${key}`}
|
key={key}
|
||||||
>
|
control={form.control}
|
||||||
{label}
|
name={`claims.flags.${key}`}
|
||||||
</label>
|
render={({ field }) => (
|
||||||
</div>
|
<FormItem className="flex items-center space-x-2">
|
||||||
</FormControl>
|
<FormControl>
|
||||||
</FormItem>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{hasRestrictedEnterpriseFeatures && (
|
||||||
|
<Alert variant="neutral" className="mt-4">
|
||||||
|
<AlertDescription>
|
||||||
|
<span>¹ </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>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ import {
|
|||||||
getUserWithSignedDocumentMonthlyGrowth,
|
getUserWithSignedDocumentMonthlyGrowth,
|
||||||
getUsersCount,
|
getUsersCount,
|
||||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
} 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 { 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 { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
|
||||||
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
||||||
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
|
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
|
||||||
@@ -42,6 +44,7 @@ export async function loader() {
|
|||||||
signerConversionMonthly,
|
signerConversionMonthly,
|
||||||
monthlyUsersWithDocuments,
|
monthlyUsersWithDocuments,
|
||||||
monthlyActiveUsers,
|
monthlyActiveUsers,
|
||||||
|
licenseData,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getUsersCount(),
|
getUsersCount(),
|
||||||
getOrganisationsWithSubscriptionsCount(),
|
getOrganisationsWithSubscriptionsCount(),
|
||||||
@@ -50,6 +53,7 @@ export async function loader() {
|
|||||||
getSignerConversionMonthly(),
|
getSignerConversionMonthly(),
|
||||||
getUserWithSignedDocumentMonthlyGrowth(),
|
getUserWithSignedDocumentMonthlyGrowth(),
|
||||||
getMonthlyActiveUsers(),
|
getMonthlyActiveUsers(),
|
||||||
|
LicenseClient.getInstance()?.getCachedLicense(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -60,6 +64,7 @@ export async function loader() {
|
|||||||
signerConversionMonthly,
|
signerConversionMonthly,
|
||||||
monthlyUsersWithDocuments,
|
monthlyUsersWithDocuments,
|
||||||
monthlyActiveUsers,
|
monthlyActiveUsers,
|
||||||
|
licenseData: licenseData || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +79,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
signerConversionMonthly,
|
signerConversionMonthly,
|
||||||
monthlyUsersWithDocuments,
|
monthlyUsersWithDocuments,
|
||||||
monthlyActiveUsers,
|
monthlyActiveUsers,
|
||||||
|
licenseData,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,6 +100,10 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
|
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8 mt-4">
|
||||||
|
<AdminLicenseCard licenseData={licenseData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 gap-8">
|
<div className="mt-16 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-3xl font-semibold">
|
<h3 className="text-3xl font-semibold">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { tsRestHonoApp } from '@documenso/api/hono';
|
|||||||
import { auth } from '@documenso/auth/server';
|
import { auth } from '@documenso/auth/server';
|
||||||
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
||||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
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 { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
||||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
@@ -140,4 +141,7 @@ if (env('NODE_ENV') !== 'development') {
|
|||||||
void TelemetryClient.start();
|
void TelemetryClient.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start license client to verify license on startup.
|
||||||
|
void LicenseClient.start();
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,10 +83,21 @@ export default defineConfig({
|
|||||||
testMatch: /e2e\/api\/.*\.spec\.ts/,
|
testMatch: /e2e\/api\/.*\.spec\.ts/,
|
||||||
workers: 10, // Limited by DB connections before it gets flakey.
|
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',
|
name: 'ui',
|
||||||
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
|
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
|
||||||
|
testIgnore: /e2e\/license\/.*\.spec\.ts/,
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
viewport: { width: 1920, height: 1200 },
|
viewport: { width: 1920, height: 1200 },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ This file lists all features currently licensed under the Documenso Enterprise E
|
|||||||
Copyright (c) 2023 Documenso, Inc
|
Copyright (c) 2023 Documenso, Inc
|
||||||
|
|
||||||
- The Stripe Billing Module
|
- The Stripe Billing Module
|
||||||
|
- Organisation Authentication Portal
|
||||||
- Document Action Reauthentication (Passkeys and 2FA)
|
- Document Action Reauthentication (Passkeys and 2FA)
|
||||||
- 21 CFR
|
- 21 CFR
|
||||||
- Email domains
|
- Email domains
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -42,6 +42,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
|||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
key: keyof TClaimFlags;
|
key: keyof TClaimFlags;
|
||||||
|
isEnterprise?: boolean;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
unlimitedDocuments: {
|
unlimitedDocuments: {
|
||||||
@@ -59,10 +60,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
|||||||
emailDomains: {
|
emailDomains: {
|
||||||
key: 'emailDomains',
|
key: 'emailDomains',
|
||||||
label: 'Email domains',
|
label: 'Email domains',
|
||||||
|
isEnterprise: true,
|
||||||
},
|
},
|
||||||
embedAuthoring: {
|
embedAuthoring: {
|
||||||
key: 'embedAuthoring',
|
key: 'embedAuthoring',
|
||||||
label: 'Embed authoring',
|
label: 'Embed authoring',
|
||||||
|
isEnterprise: true,
|
||||||
},
|
},
|
||||||
embedSigning: {
|
embedSigning: {
|
||||||
key: 'embedSigning',
|
key: 'embedSigning',
|
||||||
@@ -71,6 +74,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
|||||||
embedAuthoringWhiteLabel: {
|
embedAuthoringWhiteLabel: {
|
||||||
key: 'embedAuthoringWhiteLabel',
|
key: 'embedAuthoringWhiteLabel',
|
||||||
label: 'White label for embed authoring',
|
label: 'White label for embed authoring',
|
||||||
|
isEnterprise: true,
|
||||||
},
|
},
|
||||||
embedSigningWhiteLabel: {
|
embedSigningWhiteLabel: {
|
||||||
key: 'embedSigningWhiteLabel',
|
key: 'embedSigningWhiteLabel',
|
||||||
@@ -79,10 +83,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
|||||||
cfr21: {
|
cfr21: {
|
||||||
key: 'cfr21',
|
key: 'cfr21',
|
||||||
label: '21 CFR',
|
label: '21 CFR',
|
||||||
|
isEnterprise: true,
|
||||||
},
|
},
|
||||||
authenticationPortal: {
|
authenticationPortal: {
|
||||||
key: 'authenticationPortal',
|
key: 'authenticationPortal',
|
||||||
label: 'Authentication portal',
|
label: 'Authentication portal',
|
||||||
|
isEnterprise: true,
|
||||||
},
|
},
|
||||||
allowLegacyEnvelopes: {
|
allowLegacyEnvelopes: {
|
||||||
key: 'allowLegacyEnvelopes',
|
key: 'allowLegacyEnvelopes',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
@@ -17,6 +17,7 @@ import { getUserRoute } from './get-user';
|
|||||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||||
import { resealDocumentRoute } from './reseal-document';
|
import { resealDocumentRoute } from './reseal-document';
|
||||||
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
||||||
|
import { resyncLicenseRoute } from './resync-license';
|
||||||
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
||||||
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
|
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
|
||||||
import { updateRecipientRoute } from './update-recipient';
|
import { updateRecipientRoute } from './update-recipient';
|
||||||
@@ -44,6 +45,9 @@ export const adminRouter = router({
|
|||||||
stripe: {
|
stripe: {
|
||||||
createCustomer: createStripeCustomerRoute,
|
createCustomer: createStripeCustomerRoute,
|
||||||
},
|
},
|
||||||
|
license: {
|
||||||
|
resync: resyncLicenseRoute,
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
get: getUserRoute,
|
get: getUserRoute,
|
||||||
update: updateUserRoute,
|
update: updateUserRoute,
|
||||||
|
|||||||
Vendored
+1
@@ -2,6 +2,7 @@ declare namespace NodeJS {
|
|||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
PORT?: string;
|
PORT?: string;
|
||||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||||
|
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
||||||
"NEXT_PRIVATE_PLAIN_API_KEY",
|
"NEXT_PRIVATE_PLAIN_API_KEY",
|
||||||
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
|
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
|
||||||
|
"NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY",
|
||||||
"NEXT_PRIVATE_DATABASE_URL",
|
"NEXT_PRIVATE_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_LOGGER_FILE_PATH",
|
"NEXT_PRIVATE_LOGGER_FILE_PATH",
|
||||||
|
|||||||
Reference in New Issue
Block a user