add license system and ui

This commit is contained in:
miloschwartz 2025-04-27 13:03:00 -04:00
parent 80d76befc9
commit 4819f410e6
No known key found for this signature in database
46 changed files with 2159 additions and 94 deletions

32
LICENSE
View file

@ -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

View file

@ -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

View file

@ -52,6 +52,7 @@ esbuild
bundle: true,
outfile: argv.out,
format: "esm",
minify: true,
banner: {
js: banner,
},

126
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

View file

@ -1 +1,2 @@
export * from "./schema";
export * from "./proSchema";

View file

@ -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()
});

View file

@ -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;
}

429
server/license/license.ts Normal file
View file

@ -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<LicenseKeyCache>(key)!;
});
}
public async forceRecheck() {
this.statusCache.flushAll();
this.licenseKeyCache.flushAll();
return await this.check();
}
public async isUnlocked(): Promise<boolean> {
const status = await this.check();
if (status.isHostLicensed) {
if (status.isLicenseValid) {
return true;
}
}
return false;
}
public async check(): Promise<LicenseStatus> {
// 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<TokenPayload>(
key.token,
this.publicKey
);
this.licenseKeyCache.set<LicenseKeyCache>(
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<LicenseKeyCache>(
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<LicenseKeyCache>(
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<LicenseKeyCache>(
key.licenseKey,
cached
);
continue;
}
const payload = validateJWT<TokenPayload>(
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<LicenseKeyCache>(
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<LicenseKeyCache>(
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<TokenPayload>(
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<ValidateLicenseAPIResponse> {
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;

View file

@ -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<Payload>(
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 };

View file

@ -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);

View file

@ -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);

View file

@ -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()

View file

@ -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);

View file

@ -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<any> {
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")
);
}
}

View file

@ -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<any> {
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")
);
}
}

View file

@ -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<any> {
try {
const status = await license.check();
return sendResponse<GetLicenseStatusResponse>(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")
);
}
}

View file

@ -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";

View file

@ -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<any> {
try {
const keys = license.listKeys();
return sendResponse<ListLicenseKeysResponse>(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")
);
}
}

View file

@ -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<any> {
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")
);
}
}

View file

@ -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);

View file

@ -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

View file

@ -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() });
}

View file

@ -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() {
);
}}
/>
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
{!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be

View file

@ -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`

View file

@ -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<CreateIdpFormValues>({
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() {
);
}}
/>
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
{!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be

View file

@ -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<LicenseKeyCache>[] = [
{
accessorKey: "licenseKey",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
License Key
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const licenseKey = row.original.licenseKey;
return (
<CopyToClipboard
text={licenseKey}
displayText={obfuscateLicenseKey(licenseKey)}
/>
);
}
},
{
accessorKey: "valid",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Valid
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return row.original.valid ? "Yes" : "No";
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Type
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
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 ? (
<Badge variant={variant}>{label}</Badge>
) : null;
}
},
{
accessorKey: "numSites",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Number of Sites
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "delete",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outlinePrimary"
onClick={() => onDelete(row.original.licenseKey)}
>
Delete
</Button>
</div>
)
}
];
return (
<DataTable
columns={columns}
data={licenseKeys}
title="License Keys"
searchPlaceholder="Search license keys..."
searchColumn="licenseKey"
onAdd={onCreate}
addButtonText="Add License Key"
/>
);
}

View file

@ -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 (
<Credenza open={isOpen} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{mode === "license" ? "Purchase License" : "Purchase Additional Sites"}
</CredenzaTitle>
<CredenzaDescription>
Choose how many sites you want to {mode === "license" ? "purchase a license for" : "add to your existing license"}.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="text-sm font-medium text-muted-foreground">
Number of Sites
</div>
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="icon"
onClick={decrementSites}
disabled={siteCount <= 1}
aria-label="Decrease site count"
>
<MinusCircle className="h-5 w-5" />
</Button>
<span className="text-3xl w-12 text-center">
{siteCount}
</span>
<Button
variant="ghost"
size="icon"
onClick={incrementSites}
aria-label="Increase site count"
>
<PlusCircle className="h-5 w-5" />
</Button>
</div>
</div>
<div className="border-t pt-4">
{mode === "license" && (
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
License fee:
</span>
<span className="font-medium">
${licenseFlatRate.toFixed(2)}
</span>
</div>
)}
<div className="flex justify-between items-center mt-2">
<span className="text-sm font-medium">
Price per site:
</span>
<span className="font-medium">
${pricePerSite.toFixed(2)}
</span>
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-sm font-medium">
Number of sites:
</span>
<span className="font-medium">
{siteCount}
</span>
</div>
<div className="flex justify-between items-center mt-4 text-lg font-bold">
<span>Total:</span>
<span>${totalCost.toFixed(2)} / mo</span>
</div>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button>Continue to Payment</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View file

@ -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<LicenseKeyCache[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedLicenseKey, setSelectedLicenseKey] = useState<string | null>(
null
);
const router = useRouter();
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const [hostLicense, setHostLicense] = useState<string | null>(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<z.infer<typeof formSchema>>({
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<AxiosResponse<LicenseKeyCache[]>>(
"/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<typeof formSchema>) {
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 (
<>
<SitePriceCalculator
isOpen={isPurchaseModalOpen}
onOpenChange={(val) => {
setIsPurchaseModalOpen(val);
}}
mode={purchaseMode}
/>
<Credenza
open={isCreateModalOpen}
onOpenChange={(val) => {
setIsCreateModalOpen(val);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Activate License Key</CredenzaTitle>
<CredenzaDescription>
Enter a license key to activate it.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="activate-license-form"
>
<FormField
control={form.control}
name="licenseKey"
render={({ field }) => (
<FormItem>
<FormLabel>License Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="activate-license-form"
loading={isActivatingLicense}
disabled={isActivatingLicense}
>
Activate License
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedLicenseKey && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedLicenseKey(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to delete the license key{" "}
<b>{obfuscateLicenseKey(selectedLicenseKey)}</b>
?
</p>
<p>
<b>
This will remove the license key and all
associated permissions. Any sites using this
license key will no longer be accessible.
</b>
</p>
<p>
To confirm, please type the license key below.
</p>
</div>
}
buttonText="Confirm Delete License Key"
onConfirm={async () => deleteLicenseKey(selectedLicenseKey)}
string={selectedLicenseKey}
title="Delete License Key"
/>
)}
<SettingsSectionTitle
title="Manage License Status"
description="View and manage license keys in the system"
/>
<SettingsContainer>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>Host License</SSTitle>
<SettingsSectionDescription>
Manage the main license key for the host.
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
Licensed
</div>
</div>
) : (
<div className="space-y-2">
<div className="text-2xl">
Not Licensed
</div>
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
Host ID
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
License Key
</div>
<CopyTextBox
text={hostLicense}
displayText={obfuscateLicenseKey(
hostLicense
)}
/>
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
Recheck All Keys
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>Sites Usage</SSTitle>
<SettingsSectionDescription>
View the number of sites using this license.
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-2xl">
{licenseStatus?.usedSites || 0}{" "}
{licenseStatus?.usedSites === 1
? "site"
: "sites"}{" "}
in system
</div>
</div>
{licenseStatus?.maxSites && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{licenseStatus.usedSites || 0} of{" "}
{licenseStatus.maxSites} sites used
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedSites || 0) /
licenseStatus.maxSites) *
100
}
className="h-5"
/>
</div>
)}
</div>
<SettingsSectionFooter>
{!licenseStatus?.isHostLicensed ? (
<>
<Button
variant="outline"
onClick={() => {}}
>
View License Portal
</Button>
<Button
onClick={() => {
setPurchaseMode("license");
setIsPurchaseModalOpen(true);
}}
>
Purchase License
</Button>
</>
) : (
<Button
variant="outline"
onClick={() => {
setPurchaseMode("additional-sites");
setIsPurchaseModalOpen(true);
}}
>
Purchase Additional Sites
</Button>
)}
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {
setSelectedLicenseKey(key);
setIsDeleteModalOpen(true);
}}
onCreate={() => setIsCreateModalOpen(true)}
/>
</SettingsContainer>
</>
);
}

View file

@ -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<string | null>(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<ValidateOidcUrlCallbackResponse>

View file

@ -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 (
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
<p>
Invalid or expired license keys detected. Follow license
terms to continue using all features.
</p>
</div>
);
}
// Show usage violation banner
if (
licenseStatus.maxSites &&
licenseStatus.usedSites &&
licenseStatus.usedSites > licenseStatus.maxSites
) {
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
<p>
License Violation: Using {licenseStatus.usedSites} sites
exceeds your licensed limit of {licenseStatus.maxSites}{" "}
sites. Follow license terms to continue using all features.
</p>
</div>
);
}
return null;
}

View file

@ -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<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
);
const licenseStatus = licenseStatusRes.data.data;
return (
<html suppressHydrationWarning>
<body className={`${font.className} h-screen overflow-hidden`}>
@ -58,14 +56,19 @@ export default async function RootLayout({
disableTransitionOnChange
>
<EnvProvider env={pullEnv()}>
<SupportStatusProvider supporterStatus={supporterData}>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
{children}
<LicenseStatusProvider licenseStatus={licenseStatus}>
<SupportStatusProvider
supporterStatus={supporterData}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<LicenseViolation />
{children}
</div>
</div>
</div>
</SupportStatusProvider>
</SupportStatusProvider>
</LicenseStatusProvider>
</EnvProvider>
<Toaster />
</ThemeProvider>

View file

@ -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: <Fingerprint className="h-4 w-4" />
},
{
title: "License",
href: "/admin/license",
icon: <TicketCheck className="h-4 w-4" />
}
];

View file

@ -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<HTMLPreElement>(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"
}`}
>
<code className="block w-full">{text}</code>
<code className="block w-full">{displayText || text}</code>
</pre>
<Button
variant="ghost"

View file

@ -4,10 +4,11 @@ import { useState } from "react";
type CopyToClipboardProps = {
text: string;
displayText?: string;
isLink?: boolean;
};
const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
@ -19,6 +20,8 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
}, 2000);
};
const displayValue = displayText ?? text;
return (
<div className="flex items-center space-x-2 max-w-full">
{isLink ? (
@ -30,7 +33,7 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
title={text} // Shows full text on hover
>
{text}
{displayValue}
</Link>
) : (
<span
@ -44,7 +47,7 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
}}
title={text} // Full text tooltip
>
{text}
{displayValue}
</span>
)}
<button

View file

@ -5,14 +5,19 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
export type HorizontalTabs = Array<{
title: string;
href: string;
icon?: React.ReactNode;
showProfessional?: boolean;
}>;
interface HorizontalTabsProps {
children: React.ReactNode;
items: Array<{
title: string;
href: string;
icon?: React.ReactNode;
}>;
items: HorizontalTabs;
disabled?: boolean;
}
@ -23,6 +28,7 @@ export function HorizontalTabs({
}: HorizontalTabsProps) {
const pathname = usePathname();
const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
function hydrateHref(href: string) {
return href
@ -42,34 +48,47 @@ export function HorizontalTabs({
const isActive =
pathname.startsWith(hydratedHref) &&
!pathname.includes("create");
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled =
disabled || (isProfessional && !isUnlocked());
return (
<Link
key={hydratedHref}
href={hydratedHref}
href={isProfessional ? "#" : hydratedHref}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap",
isActive
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed"
isDisabled && "cursor-not-allowed"
)}
onClick={
disabled
? (e) => e.preventDefault()
: undefined
}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
}
}}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
{item.icon ? (
<div className="flex items-center space-x-2">
{item.icon}
<span>{item.title}</span>
</div>
) : (
item.title
)}
<div
className={cn(
"flex items-center space-x-2",
isDisabled && "opacity-60"
)}
>
{item.icon && item.icon}
<span>{item.title}</span>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
</div>
</Link>
);
})}

View file

@ -161,14 +161,6 @@ export function Layout({
>
Documentation
</Link>
<Link
href="mailto:support@fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Support
</Link>
</div>
<div>
<ProfileIcon />

View file

@ -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 (
<div
className={cn(
"relative",
isProfessional && "opacity-60 pointer-events-none"
)}
>
{isProfessional && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-50">
<div className="text-center p-6 bg-primary/10 rounded-lg">
<h3 className="text-lg font-semibold mb-2">
Professional Edition Required
</h3>
<p className="text-muted-foreground">
This feature is only available in the Professional
Edition.
</p>
</div>
</div>
)}
{children}
</div>
);
}

View file

@ -3,7 +3,7 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) {
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
return <div className="border rounded-lg bg-card p-5">{children}</div>;
return <div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">{children}</div>;
}
export function SettingsSectionHeader({
@ -47,7 +47,7 @@ export function SettingsSectionBody({
}: {
children: React.ReactNode;
}) {
return <div className="space-y-5">{children}</div>;
return <div className="space-y-5 flex-grow">{children}</div>;
}
export function SettingsSectionFooter({
@ -55,7 +55,7 @@ export function SettingsSectionFooter({
}: {
children: React.ReactNode;
}) {
return <div className="flex justify-end space-x-4 mt-8">{children}</div>;
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>;
}
export function SettingsSectionGrid({

View file

@ -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<Set<string>>(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 (

View file

@ -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",
},
},

View file

@ -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<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"border relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View file

@ -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;

View file

@ -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;
}

View file

@ -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 | null>(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 (
<LicenseStatusContext.Provider
value={{
licenseStatus: licenseStatusState,
updateLicenseStatus,
isLicenseViolation,
isUnlocked
}}
>
{children}
</LicenseStatusContext.Provider>
);
}
export default LicenseStatusProvider;