diff --git a/LICENSE b/LICENSE index 0ad25db..8c5cfb8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,35 @@ +Copyright (c) 2025 Fossorial, LLC. + +Portions of this software are licensed as follows: + +* All files that include a header specifying they are licensed under the + "Fossorial Commercial License" are governed by the Fossorial Commercial + License terms. The specific terms applicable to each customer depend on the + commercial license tier agreed upon in writing with Fossorial LLC. + Unauthorized use, copying, modification, or distribution is strictly + prohibited. + +* All files that include a header specifying they are licensed under the GNU + Affero General Public License, Version 3 ("AGPL-3"), are governed by the + AGPL-3 terms. A full copy of the AGPL-3 license is provided below. However, + these files are also available under the Fossorial Commercial License if a + separate commercial license agreement has been executed between the customer + and Fossorial LLC. + +* All files without a license header are, by default, licensed under the GNU + Affero General Public License, Version 3 (AGPL-3). These files may also be + made available under the Fossorial Commercial License upon agreement with + Fossorial LLC. + +* All third-party components included in this repository are licensed under + their respective original licenses, as provided by their authors. + +Please consult the header of each individual file to determine the applicable +license. For AGPL-3 licensed files, dual-licensing under the Fossorial +Commercial License is available subject to written agreement with Fossorial +LLC. + + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 diff --git a/README.md b/README.md index 0b130dc..2cee8bf 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta ## Licensing -Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. To see our commercial offerings, please see our [website](https://fossorial.io) for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io). +Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. Please see the [LICENSE](./LICENSE) file in the repository for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io). ## Contributions diff --git a/esbuild.mjs b/esbuild.mjs index 321c628..48a2fb3 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -52,6 +52,7 @@ esbuild bundle: true, outfile: argv.out, format: "esm", + minify: true, banner: { js: banner, }, diff --git a/package-lock.json b/package-lock.json index 812635e..5bb5834 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-icons": "1.3.2", "@radix-ui/react-label": "2.1.1", "@radix-ui/react-popover": "1.1.4", + "@radix-ui/react-progress": "^1.1.4", "@radix-ui/react-radio-group": "1.2.2", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", @@ -78,6 +79,7 @@ "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", "tw-animate-css": "^1.2.5", + "uuid": "^11.1.0", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", @@ -3351,6 +3353,101 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz", + "integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-radio-group": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz", @@ -4381,7 +4478,7 @@ "version": "7.6.12", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz", "integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4522,7 +4619,7 @@ "version": "22.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4556,7 +4653,7 @@ "version": "19.1.1", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz", "integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -4566,7 +4663,7 @@ "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -6178,7 +6275,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -9487,7 +9584,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -15865,6 +15962,7 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz", "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==", + "dev": true, "license": "MIT" }, "node_modules/tapable": { @@ -16232,6 +16330,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -16263,7 +16362,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -16378,6 +16477,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 2cca81e..cb18a7c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-icons": "1.3.2", "@radix-ui/react-label": "2.1.1", "@radix-ui/react-popover": "1.1.4", + "@radix-ui/react-progress": "^1.1.4", "@radix-ui/react-radio-group": "1.2.2", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", @@ -89,6 +90,7 @@ "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", "tw-animate-css": "^1.2.5", + "uuid": "^11.1.0", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", diff --git a/server/db/schemas/hostMeta.ts b/server/db/schemas/hostMeta.ts new file mode 100644 index 0000000..e69de29 diff --git a/server/db/schemas/index.ts b/server/db/schemas/index.ts index 686fbd9..adf6d06 100644 --- a/server/db/schemas/index.ts +++ b/server/db/schemas/index.ts @@ -1 +1,2 @@ export * from "./schema"; +export * from "./proSchema"; diff --git a/server/db/schemas/proSchema.ts b/server/db/schemas/proSchema.ts new file mode 100644 index 0000000..6b1d879 --- /dev/null +++ b/server/db/schemas/proSchema.ts @@ -0,0 +1,17 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const licenseKey = sqliteTable("licenseKey", { + licenseKeyId: text("licenseKeyId").primaryKey().notNull(), + instanceId: text("instanceId").notNull(), + token: text("token").notNull() +}); + +export const hostMeta = sqliteTable("hostMeta", { + hostMetaId: text("hostMetaId").primaryKey().notNull(), + createdAt: integer("createdAt").notNull() +}); diff --git a/server/lib/config.ts b/server/lib/config.ts index 8bac680..e967528 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -12,8 +12,8 @@ import { passwordSchema } from "@server/auth/passwordSchema"; import stoi from "./stoi"; import db from "@server/db"; import { SupporterKey, supporterKey } from "@server/db/schemas"; -import { suppressDeprecationWarnings } from "moment"; import { eq } from "drizzle-orm"; +import { license } from "@server/license/license"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -267,13 +267,19 @@ export class Config { : "false"; process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; - if (!this.isDev) { - this.checkSupporterKey(); - } + this.checkKeyStatus(); this.rawConfig = parsedConfig.data; } + private async checkKeyStatus() { + const licenseStatus = await license.check(); + console.log("License status", licenseStatus); + if (!licenseStatus.isHostLicensed) { + this.checkSupporterKey(); + } + } + public getRawConfig() { return this.rawConfig; } diff --git a/server/license/license.ts b/server/license/license.ts new file mode 100644 index 0000000..68704e2 --- /dev/null +++ b/server/license/license.ts @@ -0,0 +1,429 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import db from "@server/db"; +import { hostMeta, licenseKey, sites } from "@server/db/schemas"; +import logger from "@server/logger"; +import NodeCache from "node-cache"; +import { validateJWT } from "./licenseJwt"; +import { count, eq } from "drizzle-orm"; +import moment from "moment"; + +export type LicenseStatus = { + isHostLicensed: boolean; // Are there any license keys? + isLicenseValid: boolean; // Is the license key valid? + hostId: string; // Host ID + maxSites?: number; + usedSites?: number; +}; + +export type LicenseKeyCache = { + licenseKey: string; + valid: boolean; + iat?: Date; + type?: "LICENSE" | "SITES"; + numSites?: number; +}; + +type ActivateLicenseKeyAPIResponse = { + data: { + instanceId: string; + }; + success: boolean; + error: string; + message: string; + status: number; +}; + +type ValidateLicenseAPIResponse = { + data: { + licenseKeys: { + [key: string]: string; + }; + }; + success: boolean; + error: string; + message: string; + status: number; +}; + +type TokenPayload = { + valid: boolean; + type: "LICENSE" | "SITES"; + quantity: number; + terminateAt: string; // ISO + iat: number; // Issued at +}; + +export class License { + private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds + private validationServerUrl = + "https://api.dev.fossorial.io/api/v1/license/professional/validate"; + private activationServerUrl = + "https://api.dev.fossorial.io/api/v1/license/professional/activate"; + + private statusCache = new NodeCache({ stdTTL: this.phoneHomeInterval }); + private licenseKeyCache = new NodeCache(); + + private ephemeralKey!: string; + private statusKey = "status"; + + private publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF +FNkRDggQfYO6Ae+EWHGujZ9WYAZ10spLh9F/zoLhhr3XhsjpoRXwMfgNuO5HstWf +CYM20I0l7EUUMWEyWd4tZLd+5XQ4jY5xWOCWyFJAGQSp7flcRmxdfde+l+xg9eKl +apbY84aVp09/GqM96hCS+CsQZrhohu/aOqYVB/eAhF01qsbmiZ7Y3WtdhTldveYt +h4mZWGmjf8d/aEgePf/tk1gp0BUxf+Ae5yqoAqU+6aiFbjJ7q1kgxc18PWFGfE9y +zSk+OZk887N5ThQ52154+oOUCMMR2Y3t5OH1hVZod51vuY2u5LsQXsf+87PwB91y +LQIDAQAB +-----END PUBLIC KEY-----`; + + constructor(private hostId: string) { + this.ephemeralKey = Buffer.from( + JSON.stringify({ ts: new Date().toISOString() }) + ).toString("base64"); + + setInterval( + () => { + this.check(); + }, + 1000 * 60 * 60 + ); // 1 hour = 60 * 60 = 3600 seconds + } + + public listKeys(): LicenseKeyCache[] { + const keys = this.licenseKeyCache.keys(); + return keys.map((key) => { + return this.licenseKeyCache.get(key)!; + }); + } + + public async forceRecheck() { + this.statusCache.flushAll(); + this.licenseKeyCache.flushAll(); + + return await this.check(); + } + + public async isUnlocked(): Promise { + const status = await this.check(); + if (status.isHostLicensed) { + if (status.isLicenseValid) { + return true; + } + } + return false; + } + + public async check(): Promise { + // Set used sites + const [siteCount] = await db + .select({ + value: count() + }) + .from(sites); + + const status: LicenseStatus = { + hostId: this.hostId, + isHostLicensed: true, + isLicenseValid: false, + maxSites: undefined, + usedSites: 150 + }; + + try { + if (this.statusCache.has(this.statusKey)) { + const res = this.statusCache.get("status") as LicenseStatus; + res.usedSites = status.usedSites; + return res; + } + + // Invalidate all + this.licenseKeyCache.flushAll(); + + logger.debug("Checking license status..."); + + const allKeysRes = await db.select().from(licenseKey); + + if (allKeysRes.length === 0) { + status.isHostLicensed = false; + return status; + } + + // Validate stored license keys + for (const key of allKeysRes) { + try { + const payload = validateJWT( + key.token, + this.publicKey + ); + + this.licenseKeyCache.set( + key.licenseKeyId, + { + licenseKey: key.licenseKeyId, + valid: payload.valid, + type: payload.type, + numSites: payload.quantity, + iat: new Date(payload.iat * 1000) + } + ); + } catch (e) { + logger.error( + `Error validating license key: ${key.licenseKeyId}` + ); + logger.error(e); + + this.licenseKeyCache.set( + key.licenseKeyId, + { + licenseKey: key.licenseKeyId, + valid: false + } + ); + } + } + + const keys = allKeysRes.map((key) => ({ + licenseKey: key.licenseKeyId, + instanceId: key.instanceId + })); + + let apiResponse: ValidateLicenseAPIResponse | undefined; + try { + // Phone home to validate license keys + apiResponse = await this.phoneHome(keys); + + if (!apiResponse?.success) { + throw new Error(apiResponse?.error); + } + } catch (e) { + logger.error("Error communicating with license server:"); + logger.error(e); + } + + logger.debug("Validate response", apiResponse); + + // Check and update all license keys with server response + for (const key of keys) { + try { + const cached = this.licenseKeyCache.get( + key.licenseKey + )!; + const licenseKeyRes = + apiResponse?.data?.licenseKeys[key.licenseKey]; + + if (!apiResponse || !licenseKeyRes) { + logger.debug( + `No response from server for license key: ${key.licenseKey}` + ); + if (cached.iat) { + const exp = moment(cached.iat) + .add(7, "days") + .toDate(); + if (exp > new Date()) { + logger.debug( + `Using cached license key: ${key.licenseKey}, valid ${cached.valid}` + ); + continue; + } + } + + logger.debug( + `Can't trust license key: ${key.licenseKey}` + ); + cached.valid = false; + this.licenseKeyCache.set( + key.licenseKey, + cached + ); + continue; + } + + const payload = validateJWT( + licenseKeyRes, + this.publicKey + ); + cached.valid = payload.valid; + cached.type = payload.type; + cached.numSites = payload.quantity; + cached.iat = new Date(payload.iat * 1000); + + await db + .update(licenseKey) + .set({ + token: licenseKeyRes + }) + .where(eq(licenseKey.licenseKeyId, key.licenseKey)); + + this.licenseKeyCache.set( + key.licenseKey, + cached + ); + } catch (e) { + logger.error(`Error validating license key: ${key}`); + logger.error(e); + } + } + + // Compute host status + for (const key of keys) { + const cached = this.licenseKeyCache.get( + key.licenseKey + )!; + + logger.debug("Checking key", cached); + + if (cached.type === "LICENSE") { + status.isLicenseValid = cached.valid; + } + + if (!cached.valid) { + continue; + } + + if (!status.maxSites) { + status.maxSites = 0; + } + + status.maxSites += cached.numSites || 0; + } + } catch (error) { + logger.error("Error checking license status:"); + logger.error(error); + } + + this.statusCache.set(this.statusKey, status); + return status; + } + + public async activateLicenseKey(key: string) { + const [existingKey] = await db + .select() + .from(licenseKey) + .where(eq(licenseKey.licenseKeyId, key)) + .limit(1); + + if (existingKey) { + throw new Error("License key already exists"); + } + + let instanceId: string | undefined; + try { + // Call activate + const apiResponse = await fetch(this.activationServerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKey: key, + instanceName: this.hostId + }) + }); + + const data = await apiResponse.json(); + + if (!data.success) { + throw new Error(`${data.message || data.error}`); + } + + const response = data as ActivateLicenseKeyAPIResponse; + + if (!response.data) { + throw new Error("No response from server"); + } + + if (!response.data.instanceId) { + throw new Error("No instance ID in response"); + } + + instanceId = response.data.instanceId; + } catch (error) { + throw Error(`Error activating license key: ${error}`); + } + + // Phone home to validate license key + const keys = [ + { + licenseKey: key, + instanceId: instanceId! + } + ]; + + let validateResponse: ValidateLicenseAPIResponse; + try { + validateResponse = await this.phoneHome(keys); + + if (!validateResponse) { + throw new Error("No response from server"); + } + + if (!validateResponse.success) { + throw new Error(validateResponse.error); + } + + // Validate the license key + const licenseKeyRes = validateResponse.data.licenseKeys[key]; + if (!licenseKeyRes) { + throw new Error("Invalid license key"); + } + + const payload = validateJWT( + licenseKeyRes, + this.publicKey + ); + + if (!payload.valid) { + throw new Error("Invalid license key"); + } + + // Store the license key in the database + await db.insert(licenseKey).values({ + licenseKeyId: key, + token: licenseKeyRes, + instanceId: instanceId! + }); + } catch (error) { + throw Error(`Error validating license key: ${error}`); + } + + // Invalidate the cache and re-compute the status + return await this.forceRecheck(); + } + + private async phoneHome( + keys: { + licenseKey: string; + instanceId: string; + }[] + ): Promise { + const response = await fetch(this.validationServerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKeys: keys, + ephemeralKey: this.ephemeralKey, + instanceName: this.hostId + }) + }); + + const data = await response.json(); + + return data as ValidateLicenseAPIResponse; + } +} + +const [info] = await db.select().from(hostMeta).limit(1); + +if (!info) { + throw new Error("Host information not found"); +} + +export const license = new License(info.hostMetaId); + +export default license; diff --git a/server/license/licenseJwt.ts b/server/license/licenseJwt.ts new file mode 100644 index 0000000..ed7f4a0 --- /dev/null +++ b/server/license/licenseJwt.ts @@ -0,0 +1,114 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import * as crypto from "crypto"; + +/** + * Validates a JWT using a public key + * @param token - The JWT to validate + * @param publicKey - The public key used for verification (PEM format) + * @returns The decoded payload if validation succeeds, throws an error otherwise + */ +function validateJWT( + token: string, + publicKey: string +): Payload { + // Split the JWT into its three parts + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT format"); + } + + const [encodedHeader, encodedPayload, signature] = parts; + + // Decode the header to get the algorithm + const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString()); + const algorithm = header.alg; + + // Verify the signature + const signatureInput = `${encodedHeader}.${encodedPayload}`; + const isValid = verify(signatureInput, signature, publicKey, algorithm); + + if (!isValid) { + throw new Error("Invalid signature"); + } + + // Decode the payload + const payload = JSON.parse( + Buffer.from(encodedPayload, "base64").toString() + ); + + // Check if the token has expired + const now = Math.floor(Date.now() / 1000); + if (payload.exp && payload.exp < now) { + throw new Error("Token has expired"); + } + + return payload; +} + +/** + * Verifies the signature of a JWT + */ +function verify( + input: string, + signature: string, + publicKey: string, + algorithm: string +): boolean { + let verifyAlgorithm: string; + + // Map JWT algorithm name to Node.js crypto algorithm name + switch (algorithm) { + case "RS256": + verifyAlgorithm = "RSA-SHA256"; + break; + case "RS384": + verifyAlgorithm = "RSA-SHA384"; + break; + case "RS512": + verifyAlgorithm = "RSA-SHA512"; + break; + case "ES256": + verifyAlgorithm = "SHA256"; + break; + case "ES384": + verifyAlgorithm = "SHA384"; + break; + case "ES512": + verifyAlgorithm = "SHA512"; + break; + default: + throw new Error(`Unsupported algorithm: ${algorithm}`); + } + + // Convert base64url signature to standard base64 + const base64Signature = base64URLToBase64(signature); + + // Verify the signature + const verifier = crypto.createVerify(verifyAlgorithm); + verifier.update(input); + return verifier.verify(publicKey, base64Signature, "base64"); +} + +/** + * Converts base64url format to standard base64 + */ +function base64URLToBase64(base64url: string): string { + // Add padding if needed + let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + + const pad = base64.length % 4; + if (pad) { + if (pad === 1) { + throw new Error("Invalid base64url string"); + } + base64 += "=".repeat(4 - pad); + } + + return base64; +} + +export { validateJWT }; diff --git a/server/routers/external.ts b/server/routers/external.ts index addd922..a74e275 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -11,6 +11,7 @@ import * as role from "./role"; import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; +import * as license from "./license"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -524,6 +525,30 @@ authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); +authenticated.post( + "/license/activate", + verifyUserIsServerAdmin, + license.activateLicense +); + +authenticated.get( + "/license/keys", + verifyUserIsServerAdmin, + license.listLicenseKeys +); + +authenticated.delete( + "/license/:licenseKey", + verifyUserIsServerAdmin, + license.deleteLicenseKey +); + +authenticated.post( + "/license/recheck", + verifyUserIsServerAdmin, + license.recheckStatus +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 2591de1..910a095 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -11,6 +11,7 @@ import { idp, idpOidcConfig } from "@server/db/schemas"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import license from "@server/license/license"; const paramsSchema = z.object({}).strict(); @@ -67,7 +68,7 @@ export async function createOidcIdp( ); } - const { + let { clientId, clientSecret, authUrl, @@ -80,6 +81,10 @@ export async function createOidcIdp( autoProvision } = parsedBody.data; + if (!(await license.isUnlocked())) { + autoProvision = false; + } + const key = config.getRawConfig().server.secret; const encryptedSecret = encrypt(clientSecret, key); diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index 4eba73d..a495adb 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -11,6 +11,7 @@ import { idp, idpOidcConfig } from "@server/db/schemas"; import { eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import license from "@server/license/license"; const paramsSchema = z .object({ @@ -84,7 +85,7 @@ export async function updateOidcIdp( } const { idpId } = parsedParams.data; - const { + let { clientId, clientSecret, authUrl, @@ -99,6 +100,10 @@ export async function updateOidcIdp( defaultOrgMapping } = parsedBody.data; + if (!(await license.isUnlocked())) { + autoProvision = false; + } + // Check if IDP exists and is of type OIDC const [existingIdp] = await db .select() diff --git a/server/routers/internal.ts b/server/routers/internal.ts index aaa955e..eee72e9 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -5,6 +5,7 @@ import * as resource from "./resource"; import * as badger from "./badger"; import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; +import * as license from "@server/routers/license"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, @@ -37,6 +38,11 @@ internalRouter.get( supporterKey.isSupporterKeyVisible ); +internalRouter.get( + `/license/status`, + license.getLicenseStatus +); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); diff --git a/server/routers/license/activateLicense.ts b/server/routers/license/activateLicense.ts new file mode 100644 index 0000000..da2b76c --- /dev/null +++ b/server/routers/license/activateLicense.ts @@ -0,0 +1,62 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import license, { LicenseStatus } from "@server/license/license"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const bodySchema = z + .object({ + licenseKey: z.string().min(1).max(255) + }) + .strict(); + +export type ActivateLicenseStatus = LicenseStatus; + +export async function activateLicense( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { licenseKey } = parsedBody.data; + + try { + const status = await license.activateLicenseKey(licenseKey); + return sendResponse(res, { + data: status, + success: true, + error: false, + message: "License key activated successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) + ); + } + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/license/deleteLicenseKey.ts b/server/routers/license/deleteLicenseKey.ts new file mode 100644 index 0000000..db98e78 --- /dev/null +++ b/server/routers/license/deleteLicenseKey.ts @@ -0,0 +1,76 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; +import { licenseKey } from "@server/db/schemas"; +import license, { LicenseStatus } from "@server/license/license"; + +const paramsSchema = z + .object({ + licenseKey: z.string().min(1).max(255) + }) + .strict(); + +export type DeleteLicenseKeyResponse = LicenseStatus; + +export async function deleteLicenseKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { licenseKey: key } = parsedParams.data; + + const [existing] = await db + .select() + .from(licenseKey) + .where(eq(licenseKey.licenseKeyId, key)) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `License key ${key} not found` + ) + ); + } + + await db.delete(licenseKey).where(eq(licenseKey.licenseKeyId, key)); + + const status = await license.forceRecheck(); + + return sendResponse(res, { + data: status, + success: true, + error: false, + message: "License key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/license/getLicenseStatus.ts b/server/routers/license/getLicenseStatus.ts new file mode 100644 index 0000000..a4e4151 --- /dev/null +++ b/server/routers/license/getLicenseStatus.ts @@ -0,0 +1,36 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import license, { LicenseStatus } from "@server/license/license"; + +export type GetLicenseStatusResponse = LicenseStatus; + +export async function getLicenseStatus( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const status = await license.check(); + + return sendResponse(res, { + data: status, + success: true, + error: false, + message: "Got status", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/license/index.ts b/server/routers/license/index.ts new file mode 100644 index 0000000..6c848c2 --- /dev/null +++ b/server/routers/license/index.ts @@ -0,0 +1,10 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +export * from "./getLicenseStatus"; +export * from "./activateLicense"; +export * from "./listLicenseKeys"; +export * from "./deleteLicenseKey"; +export * from "./recheckStatus"; diff --git a/server/routers/license/listLicenseKeys.ts b/server/routers/license/listLicenseKeys.ts new file mode 100644 index 0000000..12a1956 --- /dev/null +++ b/server/routers/license/listLicenseKeys.ts @@ -0,0 +1,36 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import license, { LicenseKeyCache } from "@server/license/license"; + +export type ListLicenseKeysResponse = LicenseKeyCache[]; + +export async function listLicenseKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const keys = license.listKeys(); + + return sendResponse(res, { + data: keys, + success: true, + error: false, + message: "Successfully retrieved license keys", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/license/recheckStatus.ts b/server/routers/license/recheckStatus.ts new file mode 100644 index 0000000..5f0bd94 --- /dev/null +++ b/server/routers/license/recheckStatus.ts @@ -0,0 +1,42 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import license, { LicenseStatus } from "@server/license/license"; + +export type RecheckStatusResponse = LicenseStatus; + +export async function recheckStatus( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + try { + const status = await license.forceRecheck(); + return sendResponse(res, { + data: status, + success: true, + error: false, + message: "License status rechecked successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) + ); + } + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/supporterKey/isSupporterKeyVisible.ts b/server/routers/supporterKey/isSupporterKeyVisible.ts index 3eab2ac..15e313d 100644 --- a/server/routers/supporterKey/isSupporterKeyVisible.ts +++ b/server/routers/supporterKey/isSupporterKeyVisible.ts @@ -7,6 +7,7 @@ import config from "@server/lib/config"; import db from "@server/db"; import { count } from "drizzle-orm"; import { users } from "@server/db/schemas"; +import license from "@server/license/license"; export type IsSupporterKeyVisibleResponse = { visible: boolean; @@ -26,6 +27,12 @@ export async function isSupporterKeyVisible( let visible = !hidden && key?.valid !== true; + const licenseStatus = await license.check(); + + if (licenseStatus.isLicenseValid) { + visible = false; + } + if (key?.tier === "Limited Supporter") { const [numUsers] = await db.select({ count: count() }).from(users); diff --git a/server/setup/index.ts b/server/setup/index.ts index b93af2a..51cb358 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -3,9 +3,11 @@ import { copyInConfig } from "./copyInConfig"; import { setupServerAdmin } from "./setupServerAdmin"; import logger from "@server/logger"; import { clearStaleData } from "./clearStaleData"; +import { setHostMeta } from "./setHostMeta"; export async function runSetupFunctions() { try { + await setHostMeta(); await copyInConfig(); // copy in the config to the db as needed await setupServerAdmin(); await ensureActions(); // make sure all of the actions are in the db and the roles diff --git a/server/setup/setHostMeta.ts b/server/setup/setHostMeta.ts new file mode 100644 index 0000000..2a5b16a --- /dev/null +++ b/server/setup/setHostMeta.ts @@ -0,0 +1,17 @@ +import db from "@server/db"; +import { hostMeta } from "@server/db/schemas"; +import { v4 as uuidv4 } from "uuid"; + +export async function setHostMeta() { + const [existing] = await db.select().from(hostMeta).limit(1); + + if (existing && existing.hostMetaId) { + return; + } + + const id = uuidv4(); + + await db + .insert(hostMeta) + .values({ hostMetaId: id, createdAt: new Date().getTime() }); +} diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index 7bed134..f7844c7 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -42,6 +42,7 @@ import { } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; const GeneralFormSchema = z.object({ name: z.string().min(2, { message: "Name must be at least 2 characters." }), @@ -67,6 +68,7 @@ export default function GeneralPage() { const { idpId } = useParams(); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); + const { isUnlocked } = useLicenseStatusContext(); const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; @@ -230,7 +232,7 @@ export default function GeneralPage() { defaultChecked={form.getValues( "autoProvision" )} - disabled={true} + disabled={!isUnlocked()} onCheckedChange={(checked) => { form.setValue( "autoProvision", @@ -238,12 +240,14 @@ export default function GeneralPage() { ); }} /> - - Professional - + {!isUnlocked() && ( + + Professional + + )} When enabled, users will be diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx index 193cbe4..ebda31a 100644 --- a/src/app/admin/idp/[idpId]/layout.tsx +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -4,6 +4,7 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; @@ -35,7 +36,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { redirect("/admin/idp"); } - const navItems = [ + const navItems: HorizontalTabs = [ { title: "General", href: `/admin/idp/${params.idpId}/general` diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 3842bf7..034cc69 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -36,6 +36,7 @@ import { InfoIcon, ExternalLink } from "lucide-react"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; const createIdpFormSchema = z.object({ name: z.string().min(2, { message: "Name must be at least 2 characters." }), @@ -74,6 +75,7 @@ export default function Page() { const api = createApiClient({ env }); const router = useRouter(); const [createLoading, setCreateLoading] = useState(false); + const { isUnlocked } = useLicenseStatusContext(); const form = useForm({ resolver: zodResolver(createIdpFormSchema), @@ -190,7 +192,7 @@ export default function Page() { defaultChecked={form.getValues( "autoProvision" )} - disabled={true} + disabled={!isUnlocked()} onCheckedChange={(checked) => { form.setValue( "autoProvision", @@ -198,12 +200,14 @@ export default function Page() { ); }} /> - - Professional - + {!isUnlocked() && ( + + Professional + + )} When enabled, users will be diff --git a/src/app/admin/license/LicenseKeysDataTable.tsx b/src/app/admin/license/LicenseKeysDataTable.tsx new file mode 100644 index 0000000..dea3f38 --- /dev/null +++ b/src/app/admin/license/LicenseKeysDataTable.tsx @@ -0,0 +1,147 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { Button } from "@app/components/ui/button"; +import { Badge } from "@app/components/ui/badge"; +import { LicenseKeyCache } from "@server/license/license"; +import { ArrowUpDown } from "lucide-react"; +import moment from "moment"; +import CopyToClipboard from "@app/components/CopyToClipboard"; + +type LicenseKeysDataTableProps = { + licenseKeys: LicenseKeyCache[]; + onDelete: (key: string) => void; + onCreate: () => void; +}; + +function obfuscateLicenseKey(key: string): string { + if (key.length <= 8) return key; + const firstPart = key.substring(0, 4); + const lastPart = key.substring(key.length - 4); + return `${firstPart}••••••••••••••••••••${lastPart}`; +} + +export function LicenseKeysDataTable({ + licenseKeys, + onDelete, + onCreate +}: LicenseKeysDataTableProps) { + const columns: ColumnDef[] = [ + { + accessorKey: "licenseKey", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const licenseKey = row.original.licenseKey; + return ( + + ); + } + }, + { + accessorKey: "valid", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return row.original.valid ? "Yes" : "No"; + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + const label = + type === "SITES" ? "Additional Sites" : "Host License"; + const variant = type === "SITES" ? "secondary" : "default"; + return row.original.valid ? ( + {label} + ) : null; + } + }, + { + accessorKey: "numSites", + header: ({ column }) => { + return ( + + ); + } + }, + { + id: "delete", + cell: ({ row }) => ( +
+ +
+ ) + } + ]; + + return ( + + ); +} diff --git a/src/app/admin/license/components/SitePriceCalculator.tsx b/src/app/admin/license/components/SitePriceCalculator.tsx new file mode 100644 index 0000000..cee00a4 --- /dev/null +++ b/src/app/admin/license/components/SitePriceCalculator.tsx @@ -0,0 +1,131 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { MinusCircle, PlusCircle } from "lucide-react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; + +type SitePriceCalculatorProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + mode: "license" | "additional-sites"; +}; + +export function SitePriceCalculator({ + isOpen, + onOpenChange, + mode +}: SitePriceCalculatorProps) { + const [siteCount, setSiteCount] = useState(1); + const pricePerSite = 5; + const licenseFlatRate = 125; + + const incrementSites = () => { + setSiteCount((prev) => prev + 1); + }; + + const decrementSites = () => { + setSiteCount((prev) => (prev > 1 ? prev - 1 : 1)); + }; + + const totalCost = mode === "license" + ? licenseFlatRate + (siteCount * pricePerSite) + : siteCount * pricePerSite; + + return ( + + + + + {mode === "license" ? "Purchase License" : "Purchase Additional Sites"} + + + Choose how many sites you want to {mode === "license" ? "purchase a license for" : "add to your existing license"}. + + + +
+
+
+ Number of Sites +
+
+ + + {siteCount} + + +
+
+ +
+ {mode === "license" && ( +
+ + License fee: + + + ${licenseFlatRate.toFixed(2)} + +
+ )} +
+ + Price per site: + + + ${pricePerSite.toFixed(2)} + +
+
+ + Number of sites: + + + {siteCount} + +
+
+ Total: + ${totalCost.toFixed(2)} / mo +
+
+
+
+ + + + + + +
+
+ ); +} diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx new file mode 100644 index 0000000..153fe7a --- /dev/null +++ b/src/app/admin/license/page.tsx @@ -0,0 +1,471 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { useState, useEffect } from "react"; +import { LicenseKeyCache } from "@server/license/license"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { LicenseKeysDataTable } from "./LicenseKeysDataTable"; +import { AxiosResponse } from "axios"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { useRouter } from "next/navigation"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { + SettingsContainer, + SettingsSectionTitle as SSTitle, + SettingsSection, + SettingsSectionDescription, + SettingsSectionGrid, + SettingsSectionHeader, + SettingsSectionFooter +} from "@app/components/Settings"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Badge } from "@app/components/ui/badge"; +import { Check, ShieldCheck, ShieldOff } from "lucide-react"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { Progress } from "@app/components/ui/progress"; +import { MinusCircle, PlusCircle } from "lucide-react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { SitePriceCalculator } from "./components/SitePriceCalculator"; + +const formSchema = z.object({ + licenseKey: z + .string() + .nonempty({ message: "License key is required" }) + .max(255) +}); + +function obfuscateLicenseKey(key: string): string { + if (key.length <= 8) return key; + const firstPart = key.substring(0, 4); + const lastPart = key.substring(key.length - 4); + return `${firstPart}••••••••••••••••••••${lastPart}`; +} + +export default function LicensePage() { + const api = createApiClient(useEnvContext()); + const [rows, setRows] = useState([]); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedLicenseKey, setSelectedLicenseKey] = useState( + null + ); + const router = useRouter(); + const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext(); + const [hostLicense, setHostLicense] = useState(null); + const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false); + const [purchaseMode, setPurchaseMode] = useState< + "license" | "additional-sites" + >("license"); + + // Separate loading states for different actions + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isActivatingLicense, setIsActivatingLicense] = useState(false); + const [isDeletingLicense, setIsDeletingLicense] = useState(false); + const [isRecheckingLicense, setIsRecheckingLicense] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + licenseKey: "" + } + }); + + useEffect(() => { + async function load() { + setIsInitialLoading(true); + await loadLicenseKeys(); + setIsInitialLoading(false); + } + load(); + }, []); + + async function loadLicenseKeys() { + try { + const response = + await api.get>( + "/license/keys" + ); + const keys = response.data.data; + setRows(keys); + const hostKey = keys.find((key) => key.type === "LICENSE"); + if (hostKey) { + setHostLicense(hostKey.licenseKey); + } else { + setHostLicense(null); + } + } catch (e) { + toast({ + title: "Failed to load license keys", + description: formatAxiosError( + e, + "An error occurred loading license keys" + ) + }); + } + } + + async function deleteLicenseKey(key: string) { + try { + setIsDeletingLicense(true); + const res = await api.delete(`/license/${key}`); + if (res.data.data) { + updateLicenseStatus(res.data.data); + } + await loadLicenseKeys(); + toast({ + title: "License key deleted", + description: "The license key has been deleted" + }); + setIsDeleteModalOpen(false); + } catch (e) { + toast({ + title: "Failed to delete license key", + description: formatAxiosError( + e, + "An error occurred deleting license key" + ) + }); + } finally { + setIsDeletingLicense(false); + } + } + + async function recheck() { + try { + setIsRecheckingLicense(true); + const res = await api.post(`/license/recheck`); + if (res.data.data) { + updateLicenseStatus(res.data.data); + } + await loadLicenseKeys(); + toast({ + title: "License keys rechecked", + description: "All license keys have been rechecked" + }); + } catch (e) { + toast({ + title: "Failed to recheck license keys", + description: formatAxiosError( + e, + "An error occurred rechecking license keys" + ) + }); + } finally { + setIsRecheckingLicense(false); + } + } + + async function onSubmit(values: z.infer) { + try { + setIsActivatingLicense(true); + const res = await api.post("/license/activate", { + licenseKey: values.licenseKey + }); + if (res.data.data) { + updateLicenseStatus(res.data.data); + } + + toast({ + title: "License key activated", + description: "The license key has been successfully activated." + }); + + setIsCreateModalOpen(false); + form.reset(); + await loadLicenseKeys(); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to activate license key", + description: formatAxiosError( + e, + "An error occurred while activating the license key." + ) + }); + } finally { + setIsActivatingLicense(false); + } + } + + if (isInitialLoading) { + return null; + } + + return ( + <> + { + setIsPurchaseModalOpen(val); + }} + mode={purchaseMode} + /> + + { + setIsCreateModalOpen(val); + form.reset(); + }} + > + + + Activate License Key + + Enter a license key to activate it. + + + +
+ + ( + + License Key + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {selectedLicenseKey && ( + { + setIsDeleteModalOpen(val); + setSelectedLicenseKey(null); + }} + dialog={ +
+

+ Are you sure you want to delete the license key{" "} + {obfuscateLicenseKey(selectedLicenseKey)} + ? +

+

+ + This will remove the license key and all + associated permissions. Any sites using this + license key will no longer be accessible. + +

+

+ To confirm, please type the license key below. +

+
+ } + buttonText="Confirm Delete License Key" + onConfirm={async () => deleteLicenseKey(selectedLicenseKey)} + string={selectedLicenseKey} + title="Delete License Key" + /> + )} + + + + + + + + Host License + + Manage the main license key for the host. + + +
+
+ {licenseStatus?.isLicenseValid ? ( +
+
+ + Licensed +
+
+ ) : ( +
+
+ Not Licensed +
+
+ )} +
+ {licenseStatus?.hostId && ( +
+
+ Host ID +
+ +
+ )} + {hostLicense && ( +
+
+ License Key +
+ +
+ )} +
+ + + +
+ + + Sites Usage + + View the number of sites using this license. + + +
+
+
+ {licenseStatus?.usedSites || 0}{" "} + {licenseStatus?.usedSites === 1 + ? "site" + : "sites"}{" "} + in system +
+
+ {licenseStatus?.maxSites && ( +
+
+ + {licenseStatus.usedSites || 0} of{" "} + {licenseStatus.maxSites} sites used + + + {Math.round( + ((licenseStatus.usedSites || + 0) / + licenseStatus.maxSites) * + 100 + )} + % + +
+ +
+ )} +
+ + {!licenseStatus?.isHostLicensed ? ( + <> + + + + ) : ( + + )} + +
+
+ { + setSelectedLicenseKey(key); + setIsDeleteModalOpen(true); + }} + onCreate={() => setIsCreateModalOpen(true)} + /> +
+ + ); +} diff --git a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx index c946869..87a7683 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Loader2, CheckCircle2, AlertCircle } from "lucide-react"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; type ValidateOidcTokenParams = { orgId: string; @@ -33,6 +34,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { licenseStatus, isLicenseViolation } = useLicenseStatusContext(); + useEffect(() => { async function validate() { setLoading(true); @@ -43,6 +46,10 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { stateCookie: props.stateCookie }); + if (isLicenseViolation()) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + try { const res = await api.post< AxiosResponse diff --git a/src/app/components/LicenseViolation.tsx b/src/app/components/LicenseViolation.tsx new file mode 100644 index 0000000..6e1a58b --- /dev/null +++ b/src/app/components/LicenseViolation.tsx @@ -0,0 +1,45 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; + +export default function LicenseViolation() { + const { licenseStatus } = useLicenseStatusContext(); + + if (!licenseStatus) return null; + + // Show invalid license banner + if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) { + return ( +
+

+ Invalid or expired license keys detected. Follow license + terms to continue using all features. +

+
+ ); + } + + // Show usage violation banner + if ( + licenseStatus.maxSites && + licenseStatus.usedSites && + licenseStatus.usedSites > licenseStatus.maxSites + ) { + return ( +
+

+ License Violation: Using {licenseStatus.usedSites} sites + exceeds your licensed limit of {licenseStatus.maxSites}{" "} + sites. Follow license terms to continue using all features. +

+
+ ); + } + + return null; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1d8deae..e0089bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,25 +1,17 @@ import type { Metadata } from "next"; import "./globals.css"; -import { - Figtree, - Inter, - Red_Hat_Display, - Red_Hat_Mono, - Red_Hat_Text, - Space_Grotesk -} from "next/font/google"; +import { Inter } from "next/font/google"; import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from "@app/providers/ThemeProvider"; import EnvProvider from "@app/providers/EnvProvider"; -import { Separator } from "@app/components/ui/separator"; import { pullEnv } from "@app/lib/pullEnv"; -import { BookOpenText, ExternalLink } from "lucide-react"; -import Image from "next/image"; import SupportStatusProvider from "@app/providers/SupporterStatusProvider"; -import { createApiClient, internal, priv } from "@app/lib/api"; +import { priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey"; -import SupporterMessage from "./components/SupporterMessage"; +import LicenseStatusProvider from "@app/providers/LicenseStatusProvider"; +import { GetLicenseStatusResponse } from "@server/routers/license"; +import LicenseViolation from "./components/LicenseViolation"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, @@ -48,6 +40,12 @@ export default async function RootLayout({ supporterData.visible = res.data.data.visible; supporterData.tier = res.data.data.tier; + const licenseStatusRes = + await priv.get>( + "/license/status" + ); + const licenseStatus = licenseStatusRes.data.data; + return ( @@ -58,14 +56,19 @@ export default async function RootLayout({ disableTransitionOnChange > - - {/* Main content */} -
-
- {children} + + + {/* Main content */} +
+
+ + {children} +
-
- + + diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index cc550d7..c8b9845 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -7,7 +7,8 @@ import { Waypoints, Combine, Fingerprint, - KeyRound + KeyRound, + TicketCheck } from "lucide-react"; export const orgLangingNavItems: SidebarNavItem[] = [ @@ -93,5 +94,10 @@ export const adminNavItems: SidebarNavItem[] = [ title: "Identity Providers", href: "/admin/idp", icon: + }, + { + title: "License", + href: "/admin/license", + icon: } ]; diff --git a/src/components/CopyTextBox.tsx b/src/components/CopyTextBox.tsx index c8ba204..e600901 100644 --- a/src/components/CopyTextBox.tsx +++ b/src/components/CopyTextBox.tsx @@ -4,20 +4,26 @@ import { useState, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Copy, Check } from "lucide-react"; +type CopyTextBoxProps = { + text?: string; + displayText?: string; + wrapText?: boolean; + outline?: boolean; +}; + export default function CopyTextBox({ text = "", + displayText, wrapText = false, outline = true -}) { +}: CopyTextBoxProps) { const [isCopied, setIsCopied] = useState(false); const textRef = useRef(null); const copyToClipboard = async () => { if (textRef.current) { try { - await navigator.clipboard.writeText( - textRef.current.textContent || "" - ); + await navigator.clipboard.writeText(text); setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); } catch (err) { @@ -38,7 +44,7 @@ export default function CopyTextBox({ : "overflow-x-auto" }`} > - {text} + {displayText || text}
diff --git a/src/components/ProfessionalContentOverlay.tsx b/src/components/ProfessionalContentOverlay.tsx new file mode 100644 index 0000000..cd484a2 --- /dev/null +++ b/src/components/ProfessionalContentOverlay.tsx @@ -0,0 +1,42 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { cn } from "@app/lib/cn"; + +type ProfessionalContentOverlayProps = { + children: React.ReactNode; + isProfessional?: boolean; +}; + +export function ProfessionalContentOverlay({ + children, + isProfessional = false +}: ProfessionalContentOverlayProps) { + return ( +
+ {isProfessional && ( +
+
+

+ Professional Edition Required +

+

+ This feature is only available in the Professional + Edition. +

+
+
+ )} + {children} +
+ ); +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index f2d828e..7fa689f 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -3,7 +3,7 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) { } export function SettingsSection({ children }: { children: React.ReactNode }) { - return
{children}
; + return
{children}
; } export function SettingsSectionHeader({ @@ -47,7 +47,7 @@ export function SettingsSectionBody({ }: { children: React.ReactNode; }) { - return
{children}
; + return
{children}
; } export function SettingsSectionFooter({ @@ -55,7 +55,7 @@ export function SettingsSectionFooter({ }: { children: React.ReactNode; }) { - return
{children}
; + return
{children}
; } export function SettingsSectionGrid({ diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index d8f64db..2101699 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn"; import { ChevronDown, ChevronRight } from "lucide-react"; import { useUserContext } from "@app/hooks/useUserContext"; import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; export interface SidebarNavItem { href: string; @@ -37,6 +38,7 @@ export function SidebarNav({ const resourceId = params.resourceId as string; const userId = params.userId as string; const [expandedItems, setExpandedItems] = useState>(new Set()); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { user } = useUserContext(); @@ -98,7 +100,7 @@ export function SidebarNav({ const hasChildren = item.children && item.children.length > 0; const isExpanded = expandedItems.has(hydratedHref); const indent = level * 28; // Base indent for each level - const isProfessional = item.showProfessional; + const isProfessional = item.showProfessional && !isUnlocked(); const isDisabled = disabled || isProfessional; return ( diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index da278d1..222a234 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -16,8 +16,8 @@ const badgeVariants = cva( destructive: "border-transparent bg-destructive text-destructive-foreground", outline: "text-foreground", - green: "border-transparent bg-green-300", - yellow: "border-transparent bg-yellow-300", + green: "border-transparent bg-green-500", + yellow: "border-transparent bg-yellow-500", red: "border-transparent bg-red-300", }, }, diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..e101715 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import { cn } from "@app/lib/cn"; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/src/contexts/licenseStatusContext.ts b/src/contexts/licenseStatusContext.ts new file mode 100644 index 0000000..eca6357 --- /dev/null +++ b/src/contexts/licenseStatusContext.ts @@ -0,0 +1,20 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { LicenseStatus } from "@server/license/license"; +import { createContext } from "react"; + +type LicenseStatusContextType = { + licenseStatus: LicenseStatus | null; + updateLicenseStatus: (updatedSite: LicenseStatus) => void; + isLicenseViolation: () => boolean; + isUnlocked: () => boolean; +}; + +const LicenseStatusContext = createContext< + LicenseStatusContextType | undefined +>(undefined); + +export default LicenseStatusContext; diff --git a/src/hooks/useLicenseStatusContext.ts b/src/hooks/useLicenseStatusContext.ts new file mode 100644 index 0000000..b1da343 --- /dev/null +++ b/src/hooks/useLicenseStatusContext.ts @@ -0,0 +1,17 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import LicenseStatusContext from "@app/contexts/licenseStatusContext"; +import { useContext } from "react"; + +export function useLicenseStatusContext() { + const context = useContext(LicenseStatusContext); + if (context === undefined) { + throw new Error( + "useLicenseStatusContext must be used within an LicenseStatusProvider" + ); + } + return context; +} diff --git a/src/providers/LicenseStatusProvider.tsx b/src/providers/LicenseStatusProvider.tsx new file mode 100644 index 0000000..c3fe968 --- /dev/null +++ b/src/providers/LicenseStatusProvider.tsx @@ -0,0 +1,72 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import LicenseStatusContext from "@app/contexts/licenseStatusContext"; +import { LicenseStatus } from "@server/license/license"; +import { useState } from "react"; + +interface ProviderProps { + children: React.ReactNode; + licenseStatus: LicenseStatus | null; +} + +export function LicenseStatusProvider({ + children, + licenseStatus +}: ProviderProps) { + const [licenseStatusState, setLicenseStatusState] = + useState(licenseStatus); + + const updateLicenseStatus = (updatedLicenseStatus: LicenseStatus) => { + setLicenseStatusState((prev) => { + return { + ...updatedLicenseStatus + }; + }); + }; + + const isUnlocked = () => { + if (licenseStatusState?.isHostLicensed) { + if (licenseStatusState?.isLicenseValid) { + return true; + } + } + return false; + }; + + const isLicenseViolation = () => { + if ( + licenseStatusState?.isHostLicensed && + !licenseStatusState?.isLicenseValid + ) { + return true; + } + if ( + licenseStatusState?.maxSites && + licenseStatusState?.usedSites && + licenseStatusState.usedSites > licenseStatusState.maxSites + ) { + return true; + } + return false; + }; + + return ( + + {children} + + ); +} + +export default LicenseStatusProvider;