diff --git a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx
index bc43d1942..c99cfd0fa 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx
@@ -23,8 +23,10 @@ import {
getUserWithSignedDocumentMonthlyGrowth,
getUsersCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
+import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
+import { AdminLicenseCard } from '~/components/general/admin-license-card';
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
@@ -42,6 +44,7 @@ export async function loader() {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
+ licenseData,
] = await Promise.all([
getUsersCount(),
getOrganisationsWithSubscriptionsCount(),
@@ -50,6 +53,7 @@ export async function loader() {
getSignerConversionMonthly(),
getUserWithSignedDocumentMonthlyGrowth(),
getMonthlyActiveUsers(),
+ LicenseClient.getInstance()?.getCachedLicense(),
]);
return {
@@ -60,6 +64,7 @@ export async function loader() {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
+ licenseData: licenseData || null,
};
}
@@ -74,6 +79,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
+ licenseData,
} = loaderData;
return (
@@ -94,6 +100,10 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts
index bf091a955..12ac294ce 100644
--- a/apps/remix/server/router.ts
+++ b/apps/remix/server/router.ts
@@ -10,6 +10,7 @@ import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
+import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { env } from '@documenso/lib/utils/env';
@@ -140,4 +141,7 @@ if (env('NODE_ENV') !== 'development') {
void TelemetryClient.start();
}
+// Start license client to verify license on startup.
+void LicenseClient.start();
+
export default app;
diff --git a/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts b/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts
new file mode 100644
index 000000000..a4ea41ffa
--- /dev/null
+++ b/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts
@@ -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();
+ });
+});
diff --git a/packages/app-tests/e2e/license/license-status-banner.spec.ts b/packages/app-tests/e2e/license/license-status-banner.spec.ts
new file mode 100644
index 000000000..7c40a6b90
--- /dev/null
+++ b/packages/app-tests/e2e/license/license-status-banner.spec.ts
@@ -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();
+ });
+});
diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts
index c82e47f68..eba5f33f1 100644
--- a/packages/app-tests/playwright.config.ts
+++ b/packages/app-tests/playwright.config.ts
@@ -83,10 +83,21 @@ export default defineConfig({
testMatch: /e2e\/api\/.*\.spec\.ts/,
workers: 10, // Limited by DB connections before it gets flakey.
},
- // Run UI Tests
+ // License tests that share a single license file - must run serially
+ {
+ name: 'license',
+ testMatch: /e2e\/license\/.*\.spec\.ts/,
+ use: {
+ ...devices['Desktop Chrome'],
+ viewport: { width: 1920, height: 1200 },
+ },
+ workers: 1, // Must run serially since they share a license file
+ },
+ // Run UI Tests (excluding license tests which have their own project)
{
name: 'ui',
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
+ testIgnore: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },
diff --git a/packages/ee/FEATURES b/packages/ee/FEATURES
index c557e79ef..50896cb3e 100644
--- a/packages/ee/FEATURES
+++ b/packages/ee/FEATURES
@@ -2,6 +2,7 @@ This file lists all features currently licensed under the Documenso Enterprise E
Copyright (c) 2023 Documenso, Inc
- The Stripe Billing Module
+- Organisation Authentication Portal
- Document Action Reauthentication (Passkeys and 2FA)
- 21 CFR
- Email domains
diff --git a/packages/lib/server-only/license/license-client.ts b/packages/lib/server-only/license/license-client.ts
new file mode 100644
index 000000000..0c03047dc
--- /dev/null
+++ b/packages/lib/server-only/license/license-client.ts
@@ -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 {
+ 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 {
+ 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 {
+ await this.initialize();
+ }
+
+ private async initialize(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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): Promise {
+ // 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;
+ }
+}
diff --git a/packages/lib/types/license.ts b/packages/lib/types/license.ts
new file mode 100644
index 000000000..b0194fff3
--- /dev/null
+++ b/packages/lib/types/license.ts
@@ -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;
+export type TLicenseRequest = z.infer;
+export type TLicenseResponse = z.infer;
+
+/**
+ * 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;
+
+export const LICENSE_FILE_NAME = '.documenso-license.json';
diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts
index eba408cd8..580994428 100644
--- a/packages/lib/types/subscription.ts
+++ b/packages/lib/types/subscription.ts
@@ -42,6 +42,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
{
label: string;
key: keyof TClaimFlags;
+ isEnterprise?: boolean;
}
> = {
unlimitedDocuments: {
@@ -59,10 +60,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
emailDomains: {
key: 'emailDomains',
label: 'Email domains',
+ isEnterprise: true,
},
embedAuthoring: {
key: 'embedAuthoring',
label: 'Embed authoring',
+ isEnterprise: true,
},
embedSigning: {
key: 'embedSigning',
@@ -71,6 +74,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
embedAuthoringWhiteLabel: {
key: 'embedAuthoringWhiteLabel',
label: 'White label for embed authoring',
+ isEnterprise: true,
},
embedSigningWhiteLabel: {
key: 'embedSigningWhiteLabel',
@@ -79,10 +83,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
cfr21: {
key: 'cfr21',
label: '21 CFR',
+ isEnterprise: true,
},
authenticationPortal: {
key: 'authenticationPortal',
label: 'Authentication portal',
+ isEnterprise: true,
},
allowLegacyEnvelopes: {
key: 'allowLegacyEnvelopes',
diff --git a/packages/trpc/server/admin-router/resync-license.ts b/packages/trpc/server/admin-router/resync-license.ts
new file mode 100644
index 000000000..64048ce2c
--- /dev/null
+++ b/packages/trpc/server/admin-router/resync-license.ts
@@ -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();
+ });
diff --git a/packages/trpc/server/admin-router/resync-license.types.ts b/packages/trpc/server/admin-router/resync-license.types.ts
new file mode 100644
index 000000000..652d4b588
--- /dev/null
+++ b/packages/trpc/server/admin-router/resync-license.types.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+export const ZResyncLicenseRequestSchema = z.void();
+
+export const ZResyncLicenseResponseSchema = z.void();
+
+export type TResyncLicenseRequest = z.infer;
+export type TResyncLicenseResponse = z.infer;
diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts
index 7e1a7d5d7..c169e4813 100644
--- a/packages/trpc/server/admin-router/router.ts
+++ b/packages/trpc/server/admin-router/router.ts
@@ -17,6 +17,7 @@ import { getUserRoute } from './get-user';
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
+import { resyncLicenseRoute } from './resync-license';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
@@ -44,6 +45,9 @@ export const adminRouter = router({
stripe: {
createCustomer: createStripeCustomerRoute,
},
+ license: {
+ resync: resyncLicenseRoute,
+ },
user: {
get: getUserRoute,
update: updateUserRoute,
diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts
index 5a79b386f..4398d5fd2 100644
--- a/packages/tsconfig/process-env.d.ts
+++ b/packages/tsconfig/process-env.d.ts
@@ -2,6 +2,7 @@ declare namespace NodeJS {
export interface ProcessEnv {
PORT?: string;
NEXT_PUBLIC_WEBAPP_URL?: string;
+ NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY?: string;
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
diff --git a/turbo.json b/turbo.json
index 7f54d9862..8bf840e53 100644
--- a/turbo.json
+++ b/turbo.json
@@ -50,6 +50,7 @@
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
+ "NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
"NEXT_PRIVATE_LOGGER_FILE_PATH",