add enterprise license system

This commit is contained in:
miloschwartz
2025-10-13 10:41:10 -07:00
parent 6b125bba7c
commit 37ceabdf5d
76 changed files with 3886 additions and 1931 deletions

View File

@@ -1,10 +1,9 @@
import { defineConfig } from "drizzle-kit";
import path from "path";
import { build } from "@server/build";
const schema = [
path.join("server", "db", "pg", "schema.ts"),
path.join("server", "db", "pg", "pSchema.ts")
path.join("server", "db", "pg", "privateSchema.ts")
];
export default defineConfig({

View File

@@ -1,11 +1,10 @@
import { build } from "@server/build";
import { APP_PATH } from "@server/lib/consts";
import { defineConfig } from "drizzle-kit";
import path from "path";
const schema = [
path.join("server", "db", "sqlite", "schema.ts"),
path.join("server", "db", "sqlite", "pSchema.ts")
path.join("server", "db", "sqlite", "privateSchema.ts")
];
export default defineConfig({

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Администратор на сървър - Панголин",
"licenseTierProfessional": "Професионален лиценз",
"licenseTierEnterprise": "Предприятие лиценз",
"licenseTierCommercial": "Търговски лиценз",
"licensed": "Лицензиран",
"yes": "Да",
"no": "Не",
@@ -1084,7 +1083,6 @@
"navbar": "Навигационно меню",
"navbarDescription": "Главно навигационно меню за приложението",
"navbarDocsLink": "Документация",
"commercialEdition": "Търговско издание",
"otpErrorEnable": "Не може да се активира 2FA",
"otpErrorEnableDescription": "Възникна грешка при активиране на 2FA",
"otpSetupCheckCode": "Моля, въведете 6-цифрен код",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Správce serveru - Pangolin",
"licenseTierProfessional": "Profesionální licence",
"licenseTierEnterprise": "Podniková licence",
"licenseTierCommercial": "Obchodní licence",
"licensed": "Licencováno",
"yes": "Ano",
"no": "Ne",
@@ -1084,7 +1083,6 @@
"navbar": "Navigation Menu",
"navbarDescription": "Hlavní navigační menu aplikace",
"navbarDocsLink": "Dokumentace",
"commercialEdition": "Obchodní vydání",
"otpErrorEnable": "2FA nelze povolit",
"otpErrorEnableDescription": "Došlo k chybě při povolování 2FA",
"otpSetupCheckCode": "Zadejte 6místný kód",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Server-Admin - Pangolin",
"licenseTierProfessional": "Professional Lizenz",
"licenseTierEnterprise": "Enterprise Lizenz",
"licenseTierCommercial": "Gewerbliche Lizenz",
"licensed": "Lizenziert",
"yes": "Ja",
"no": "Nein",
@@ -1084,7 +1083,6 @@
"navbar": "Navigationsmenü",
"navbarDescription": "Hauptnavigationsmenü für die Anwendung",
"navbarDocsLink": "Dokumentation",
"commercialEdition": "Kommerzielle Edition",
"otpErrorEnable": "2FA konnte nicht aktiviert werden",
"otpErrorEnableDescription": "Beim Aktivieren der 2FA ist ein Fehler aufgetreten",
"otpSetupCheckCode": "Bitte geben Sie einen 6-stelligen Code ein",

View File

@@ -715,7 +715,7 @@
"pangolinServerAdmin": "Server Admin - Pangolin",
"licenseTierProfessional": "Professional License",
"licenseTierEnterprise": "Enterprise License",
"licenseTierCommercial": "Commercial License",
"licenseTierPersonal": "Personal License",
"licensed": "Licensed",
"yes": "Yes",
"no": "No",
@@ -750,7 +750,7 @@
"idpDisplayName": "A display name for this identity provider",
"idpAutoProvisionUsers": "Auto Provision Users",
"idpAutoProvisionUsersDescription": "When enabled, users will be automatically created in the system upon first login with the ability to map users to roles and organizations.",
"licenseBadge": "Professional",
"licenseBadge": "EE",
"idpType": "Provider Type",
"idpTypeDescription": "Select the type of identity provider you want to configure",
"idpOidcConfigure": "OAuth2/OIDC Configuration",
@@ -1084,7 +1084,6 @@
"navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application",
"navbarDocsLink": "Documentation",
"commercialEdition": "Commercial Edition",
"otpErrorEnable": "Unable to enable 2FA",
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
"otpSetupCheckCode": "Please enter a 6-digit code",
@@ -1140,7 +1139,7 @@
"sidebarAllUsers": "All Users",
"sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License",
"sidebarClients": "Clients (Beta)",
"sidebarClients": "Clients",
"sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
@@ -1216,7 +1215,7 @@
"refreshError": "Failed to refresh data",
"verified": "Verified",
"pending": "Pending",
"sidebarBilling": "Billing",
"sidebarBilling": "Payment & Billing",
"billing": "Billing",
"orgBillingDescription": "Manage your billing information and subscriptions",
"github": "GitHub",
@@ -1740,5 +1739,106 @@
"resourceHeaderAuthSetupTitle": "Set Header Authentication",
"resourceHeaderAuthSetupTitleDescription": "Set the basic auth credentials (username and password) to protect this resource with HTTP Header Authentication. Leave both fields blank to remove existing header authentication.",
"resourceHeaderAuthSubmit": "Set Header Authentication",
"actionSetResourceHeaderAuth": "Set Header Authentication"
"actionSetResourceHeaderAuth": "Set Header Authentication",
"enterpriseEdition": "Enterprise Edition",
"unlicensed": "Unlicensed",
"beta": "Beta",
"manageClients": "Manage Clients",
"manageClientsDescription": "Clients are devices that can connect to your sites",
"licenseTableValidUntil": "Valid Until",
"saasLicenseKeysSettingsTitle": "Enterprise Licenses",
"saasLicenseKeysSettingsDescription": "Generate and manage Enterprise license keys for self-hosted Pangolin instances",
"sidebarEnterpriseLicenses": "Enterprise Licenses",
"generateLicenseKey": "Generate License Key",
"generateLicenseKeyForm": {
"validation": {
"emailRequired": "Please enter a valid email address",
"useCaseTypeRequired": "Please select a use case type",
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",
"primaryUseRequired": "Please describe your primary use",
"jobTitleRequiredBusiness": "Job title is required for business use",
"industryRequiredBusiness": "Industry is required for business use",
"stateProvinceRegionRequired": "State/Province/Region is required",
"postalZipCodeRequired": "Postal/ZIP Code is required",
"companyNameRequiredBusiness": "Company name is required for business use",
"countryOfResidenceRequiredBusiness": "Country of residence is required for business use",
"countryRequiredPersonal": "Country is required for personal use",
"agreeToTermsRequired": "You must agree to the terms",
"complianceConfirmationRequired": "You must confirm compliance with the Fossorial Commercial License"
},
"useCaseOptions": {
"personal": {
"title": "Personal Use",
"description": "For individual, non-commercial use such as learning, personal projects, or experimentation."
},
"business": {
"title": "Business Use",
"description": "For use within organizations, companies, or any commercial or revenue-generating activities."
}
},
"steps": {
"emailLicenseType": {
"title": "Email & License Type",
"description": "Enter your email and choose your license type"
},
"personalInformation": {
"title": "Personal Information",
"description": "Tell us about yourself"
},
"contactInformation": {
"title": "Contact Information",
"description": "Your contact details"
},
"termsGenerate": {
"title": "Terms & Generate",
"description": "Review and accept terms to generate your license"
}
},
"alerts": {
"commercialUseDisclosure": {
"title": "Usage Disclosure",
"description": "Select the license type that accurately reflects your intended use. Pangolin Enterprise is free for personal, non-commercial use only — limited to individuals generating less than $100,000 USD annually and not used in primary employment, business operations, or other commercial environments. All business or revenue-generating use requires a valid Business License and payment of the applicable licensing fee. Both personal and business users must comply with the Fossorial Commercial License Terms."
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This license key is valid for 7 days as a trial period. For a long-term license key, please contact sales@fossorial.io."
}
},
"form": {
"useCaseQuestion": "Are you using Pangolin for personal or business use?",
"firstName": "First Name",
"lastName": "Last Name",
"jobTitle": "Job Title",
"primaryUseQuestion": "What do you primarily plan to use Pangolin for?",
"industryQuestion": "What is your industry?",
"prospectiveUsersQuestion": "How many prospective users do you expect to have?",
"prospectiveSitesQuestion": "How many prospective sites (tunnels) do you expect to have?",
"companyName": "Company name",
"countryOfResidence": "Country of residence",
"stateProvinceRegion": "State / Province / Region",
"postalZipCode": "Postal / ZIP Code",
"companyWebsite": "Company website",
"companyPhoneNumber": "Company phone number",
"country": "Country",
"phoneNumberOptional": "Phone number (optional)",
"complianceConfirmation": "I confirm that I am in compliance with the Fossorial Commercial License and that reporting inaccurate information or misidentifying use of the product is a violation of the license."
},
"buttons": {
"close": "Close",
"previous": "Previous",
"next": "Next",
"generateLicenseKey": "Generate License Key"
},
"toasts": {
"success": {
"title": "License key generated successfully",
"description": "Your license key has been generated and is ready to use."
},
"error": {
"title": "Failed to generate license key",
"description": "An error occurred while generating the license key."
}
}
}
}

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Admin Servidor - Pangolin",
"licenseTierProfessional": "Licencia profesional",
"licenseTierEnterprise": "Licencia Enterprise",
"licenseTierCommercial": "Licencia comercial",
"licensed": "Licenciado",
"yes": "Sí",
"no": "Nu",
@@ -1084,7 +1083,6 @@
"navbar": "Menú de navegación",
"navbarDescription": "Menú de navegación principal para la aplicación",
"navbarDocsLink": "Documentación",
"commercialEdition": "Edición Comercial",
"otpErrorEnable": "No se puede habilitar 2FA",
"otpErrorEnableDescription": "Se ha producido un error al habilitar 2FA",
"otpSetupCheckCode": "Por favor, introduzca un código de 6 dígitos",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Admin Serveur - Pangolin",
"licenseTierProfessional": "Licence Professionnelle",
"licenseTierEnterprise": "Licence Entreprise",
"licenseTierCommercial": "Licence commerciale",
"licensed": "Sous licence",
"yes": "Oui",
"no": "Non",
@@ -1084,7 +1083,6 @@
"navbar": "Menu de navigation",
"navbarDescription": "Menu de navigation principal de l'application",
"navbarDocsLink": "Documentation",
"commercialEdition": "Édition Commerciale",
"otpErrorEnable": "Impossible d'activer l'A2F",
"otpErrorEnableDescription": "Une erreur s'est produite lors de l'activation de l'A2F",
"otpSetupCheckCode": "Veuillez entrer un code à 6 chiffres",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Server Admin - Pangolina",
"licenseTierProfessional": "Licenza Professional",
"licenseTierEnterprise": "Licenza Enterprise",
"licenseTierCommercial": "Licenza Commerciale",
"licensed": "Con Licenza",
"yes": "Sì",
"no": "No",
@@ -1084,7 +1083,6 @@
"navbar": "Menu di Navigazione",
"navbarDescription": "Menu di navigazione principale dell'applicazione",
"navbarDocsLink": "Documentazione",
"commercialEdition": "Edizione Commerciale",
"otpErrorEnable": "Impossibile abilitare 2FA",
"otpErrorEnableDescription": "Si è verificato un errore durante l'abilitazione di 2FA",
"otpSetupCheckCode": "Inserisci un codice a 6 cifre",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "서버 관리자 - 판골린",
"licenseTierProfessional": "전문 라이센스",
"licenseTierEnterprise": "기업 라이선스",
"licenseTierCommercial": "상업용 라이선스",
"licensed": "라이센스",
"yes": "예",
"no": "아니요",
@@ -1084,7 +1083,6 @@
"navbar": "탐색 메뉴",
"navbarDescription": "애플리케이션의 주요 탐색 메뉴",
"navbarDocsLink": "문서",
"commercialEdition": "상업용 에디션",
"otpErrorEnable": "2FA를 활성화할 수 없습니다.",
"otpErrorEnableDescription": "2FA를 활성화하는 동안 오류가 발생했습니다",
"otpSetupCheckCode": "6자리 코드를 입력하세요",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Server Admin - Pangolin",
"licenseTierProfessional": "Profesjonell lisens",
"licenseTierEnterprise": "Bedriftslisens",
"licenseTierCommercial": "Kommersiell lisens",
"licensed": "Lisensiert",
"yes": "Ja",
"no": "Nei",
@@ -1084,7 +1083,6 @@
"navbar": "Navigasjonsmeny",
"navbarDescription": "Hovednavigasjonsmeny for applikasjonen",
"navbarDocsLink": "Dokumentasjon",
"commercialEdition": "Kommersiell utgave",
"otpErrorEnable": "Kunne ikke aktivere 2FA",
"otpErrorEnableDescription": "En feil oppstod under aktivering av 2FA",
"otpSetupCheckCode": "Vennligst skriv inn en 6-sifret kode",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Serverbeheer - Pangolin",
"licenseTierProfessional": "Professionele licentie",
"licenseTierEnterprise": "Enterprise Licentie",
"licenseTierCommercial": "Commerciële licentie",
"licensed": "Gelicentieerd",
"yes": "ja",
"no": "Neen",
@@ -1084,7 +1083,6 @@
"navbar": "Navigatiemenu",
"navbarDescription": "Hoofd navigatie menu voor de applicatie",
"navbarDocsLink": "Documentatie",
"commercialEdition": "Commerciële editie",
"otpErrorEnable": "Kan 2FA niet inschakelen",
"otpErrorEnableDescription": "Er is een fout opgetreden tijdens het inschakelen van 2FA",
"otpSetupCheckCode": "Voer een 6-cijferige code in",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Administrator serwera - Pangolin",
"licenseTierProfessional": "Licencja Professional",
"licenseTierEnterprise": "Licencja Enterprise",
"licenseTierCommercial": "Licencja handlowa",
"licensed": "Licencjonowany",
"yes": "Tak",
"no": "Nie",
@@ -1084,7 +1083,6 @@
"navbar": "Menu nawigacyjne",
"navbarDescription": "Główne menu nawigacyjne aplikacji",
"navbarDocsLink": "Dokumentacja",
"commercialEdition": "Edycja komercyjna",
"otpErrorEnable": "Nie można włączyć 2FA",
"otpErrorEnableDescription": "Wystąpił błąd podczas włączania 2FA",
"otpSetupCheckCode": "Wprowadź 6-cyfrowy kod",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Administrador do Servidor - Pangolin",
"licenseTierProfessional": "Licença Profissional",
"licenseTierEnterprise": "Licença Empresarial",
"licenseTierCommercial": "Licença comercial",
"licensed": "Licenciado",
"yes": "Sim",
"no": "Não",
@@ -1084,7 +1083,6 @@
"navbar": "Menu de Navegação",
"navbarDescription": "Menu de navegação principal da aplicação",
"navbarDocsLink": "Documentação",
"commercialEdition": "Edição Comercial",
"otpErrorEnable": "Não foi possível ativar 2FA",
"otpErrorEnableDescription": "Ocorreu um erro ao ativar 2FA",
"otpSetupCheckCode": "Por favor, insira um código de 6 dígitos",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Администратор сервера - Pangolin",
"licenseTierProfessional": "Профессиональная лицензия",
"licenseTierEnterprise": "Корпоративная лицензия",
"licenseTierCommercial": "Коммерческая лицензия",
"licensed": "Лицензировано",
"yes": "Да",
"no": "Нет",
@@ -1084,7 +1083,6 @@
"navbar": "Навигационное меню",
"navbarDescription": "Главное навигационное меню приложения",
"navbarDocsLink": "Документация",
"commercialEdition": "Коммерческая версия",
"otpErrorEnable": "Невозможно включить 2FA",
"otpErrorEnableDescription": "Произошла ошибка при включении 2FA",
"otpSetupCheckCode": "Пожалуйста, введите 6-значный код",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "Sunucu Yöneticisi - Pangolin",
"licenseTierProfessional": "Profesyonel Lisans",
"licenseTierEnterprise": "Kurumsal Lisans",
"licenseTierCommercial": "Ticari Lisans",
"licensed": "Lisanslı",
"yes": "Evet",
"no": "Hayır",
@@ -1084,7 +1083,6 @@
"navbar": "Navigasyon Menüsü",
"navbarDescription": "Uygulamanın ana navigasyon menüsü",
"navbarDocsLink": "Dokümantasyon",
"commercialEdition": "Ticari Sürüm",
"otpErrorEnable": "2FA etkinleştirilemedi",
"otpErrorEnableDescription": "2FA etkinleştirilirken bir hata oluştu",
"otpSetupCheckCode": "6 haneli bir kod girin",

View File

@@ -715,7 +715,6 @@
"pangolinServerAdmin": "服务器管理员 - Pangolin",
"licenseTierProfessional": "专业许可证",
"licenseTierEnterprise": "企业许可证",
"licenseTierCommercial": "商业许可证",
"licensed": "已授权",
"yes": "是",
"no": "否",
@@ -1084,7 +1083,6 @@
"navbar": "导航菜单",
"navbarDescription": "应用程序的主导航菜单",
"navbarDocsLink": "文件",
"commercialEdition": "商业版",
"otpErrorEnable": "无法启用 2FA",
"otpErrorEnableDescription": "启用 2FA 时出错",
"otpSetupCheckCode": "请输入您的6位数字代码",

View File

@@ -3,10 +3,11 @@ import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import { db } from "@server/db";
import { SupporterKey, supporterKey } from "@server/db";
import { eq } from "drizzle-orm";
import { license } from "@server/license/license";
import { license } from "#dynamic/license/license";
import { configSchema, readConfigFile } from "./readConfigFile";
import { fromError } from "zod-validation-error";
import { build } from "@server/build";
import logger from "@server/logger";
export class Config {
private rawConfig!: z.infer<typeof configSchema>;
@@ -111,11 +112,11 @@ export class Config {
}
private async checkKeyStatus() {
const licenseStatus = await license.check();
if (
build != "oss" &&
!licenseStatus.isHostLicensed
) {
if (build === "enterprise") {
await license.check();
}
if (build == "oss") {
this.checkSupporterKey();
}
}

View File

@@ -12,7 +12,8 @@ const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
export const configSchema = z
.object({
app: z.object({
app: z
.object({
dashboard_url: z
.string()
.url()
@@ -30,8 +31,10 @@ export const configSchema = z
anonymous_usage: z.boolean().optional().default(true)
})
.optional()
.default({}),
}).optional().default({
.default({})
})
.optional()
.default({
log_level: "info",
save_logs: false,
log_failed_attempts: false,
@@ -44,7 +47,10 @@ export const configSchema = z
name: z.string().optional(),
id: z.string().optional(),
secret: z.string().optional(),
endpoint: z.string().optional().default("https://pangolin.fossorial.io"),
endpoint: z
.string()
.optional()
.default("https://pangolin.fossorial.io"),
redirect_endpoint: z.string().optional()
})
.optional(),
@@ -61,7 +67,8 @@ export const configSchema = z
})
)
.optional(),
server: z.object({
server: z
.object({
integration_port: portSchema
.optional()
.default(3003)
@@ -127,12 +134,11 @@ export const configSchema = z
})
.optional(),
trust_proxy: z.number().int().gte(0).optional().default(1),
secret: z
.string()
.pipe(z.string().min(8))
.optional(),
secret: z.string().pipe(z.string().min(8)).optional(),
maxmind_db_path: z.string().optional()
}).optional().default({
})
.optional()
.default({
integration_port: 3003,
external_port: 3000,
internal_port: 3001,
@@ -144,7 +150,8 @@ export const configSchema = z
id: "P-Access-Token-Id",
token: "P-Access-Token"
},
resource_session_request_param: "resource_session_request_param",
resource_session_request_param:
"resource_session_request_param",
dashboard_session_length_hours: 720,
resource_session_length_hours: 720,
trust_proxy: 1
@@ -161,10 +168,26 @@ export const configSchema = z
.optional(),
pool: z
.object({
max_connections: z.number().positive().optional().default(20),
max_replica_connections: z.number().positive().optional().default(10),
idle_timeout_ms: z.number().positive().optional().default(30000),
connection_timeout_ms: z.number().positive().optional().default(5000)
max_connections: z
.number()
.positive()
.optional()
.default(20),
max_replica_connections: z
.number()
.positive()
.optional()
.default(10),
idle_timeout_ms: z
.number()
.positive()
.optional()
.default(30000),
connection_timeout_ms: z
.number()
.positive()
.optional()
.default(5000)
})
.optional()
.default({
@@ -193,7 +216,10 @@ export const configSchema = z
.optional()
.default("/var/dynamic/router_config.yml"),
static_domains: z.array(z.string()).optional().default([]),
site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]),
site_types: z
.array(z.string())
.optional()
.default(["newt", "wireguard", "local"]),
allow_raw_resources: z.boolean().optional().default(true),
file_mode: z.boolean().optional().default(false)
})
@@ -343,7 +369,10 @@ export const configSchema = z
if (data.server?.secret === undefined) {
data.server.secret = process.env.SERVER_SECRET;
}
return data.server?.secret !== undefined && data.server.secret.length > 0;
return (
data.server?.secret !== undefined &&
data.server.secret.length > 0
);
},
{
message: "Server secret must be defined"
@@ -356,7 +385,10 @@ export const configSchema = z
return true;
}
// If hybrid is not defined, dashboard_url must be defined
return data.app.dashboard_url !== undefined && data.app.dashboard_url.length > 0;
return (
data.app.dashboard_url !== undefined &&
data.app.dashboard_url.length > 0
);
},
{
message: "Dashboard URL must be defined"

View File

@@ -9,6 +9,7 @@ import { APP_VERSION } from "./consts";
import crypto from "crypto";
import { UserType } from "@server/types/UserTypes";
import { build } from "@server/build";
import license from "@server/license/license";
class TelemetryClient {
private client: PostHog | null = null;
@@ -176,6 +177,24 @@ class TelemetryClient {
const stats = await this.getSystemStats();
if (build === "enterprise") {
const licenseStatus = await license.check();
const payload = {
distinctId: hostMeta.hostMetaId,
event: "enterprise_status",
properties: {
is_host_licensed: licenseStatus.isHostLicensed,
is_license_valid: licenseStatus.isLicenseValid,
license_tier: licenseStatus.tier || "unknown"
}
};
logger.debug("Sending enterprise startup telemtry payload:", {
payload
});
// this.client.capture(payload);
}
if (build === "oss") {
this.client.capture({
distinctId: hostMeta.hostMetaId,
event: "supporter_status",
@@ -187,6 +206,7 @@ class TelemetryClient {
: "None"
}
});
}
this.client.capture({
distinctId: hostMeta.hostMetaId,

View File

@@ -1,26 +1,17 @@
import { db } from "@server/db";
import { hostMeta, licenseKey, sites } from "@server/db";
import logger from "@server/logger";
import NodeCache from "node-cache";
import { validateJWT } from "./licenseJwt";
import { count, eq } from "drizzle-orm";
import moment from "moment";
import { db, hostMeta, HostMeta } from "@server/db";
import { setHostMeta } from "@server/lib/hostMeta";
import { encrypt, decrypt } from "@server/lib/crypto";
const keyTypes = ["HOST", "SITES"] as const;
type KeyType = (typeof keyTypes)[number];
const keyTypes = ["host"] as const;
export type LicenseKeyType = (typeof keyTypes)[number];
const keyTiers = ["PROFESSIONAL", "ENTERPRISE"] as const;
type KeyTier = (typeof keyTiers)[number];
const keyTiers = ["personal", "enterprise"] as const;
export type LicenseKeyTier = (typeof keyTiers)[number];
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;
tier?: KeyTier;
tier?: LicenseKeyTier;
};
export type LicenseKeyCache = {
@@ -28,451 +19,27 @@ export type LicenseKeyCache = {
licenseKeyEncrypted: string;
valid: boolean;
iat?: Date;
type?: KeyType;
tier?: KeyTier;
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: KeyType;
tier: KeyTier;
quantity: number;
terminateAt: string; // ISO
iat: number; // Issued at
type?: LicenseKeyType;
tier?: LicenseKeyTier;
terminateAt?: Date;
};
export class License {
private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds
private validationServerUrl =
"https://api.fossorial.io/api/v1/license/professional/validate";
private activationServerUrl =
"https://api.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 serverSecret!: string;
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 hostMeta: HostMeta) {}
constructor(private hostId: string) {
this.ephemeralKey = Buffer.from(
JSON.stringify({ ts: new Date().toISOString() })
).toString("base64");
setInterval(
async () => {
await 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 check(): Promise<LicenseStatus> {
return {
hostId: this.hostMeta.hostMetaId,
isHostLicensed: false,
isLicenseValid: false
};
}
public setServerSecret(secret: string) {
this.serverSecret = secret;
}
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: siteCount.value
};
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();
const allKeysRes = await db.select().from(licenseKey);
if (allKeysRes.length === 0) {
status.isHostLicensed = false;
return status;
}
let foundHostKey = false;
// Validate stored license keys
for (const key of allKeysRes) {
try {
// Decrypt the license key and token
const decryptedKey = decrypt(
key.licenseKeyId,
this.serverSecret
);
const decryptedToken = decrypt(
key.token,
this.serverSecret
);
const payload = validateJWT<TokenPayload>(
decryptedToken,
this.publicKey
);
this.licenseKeyCache.set<LicenseKeyCache>(decryptedKey, {
licenseKey: decryptedKey,
licenseKeyEncrypted: key.licenseKeyId,
valid: payload.valid,
type: payload.type,
tier: payload.tier,
numSites: payload.quantity,
iat: new Date(payload.iat * 1000)
});
if (payload.type === "HOST") {
foundHostKey = true;
}
} catch (e) {
logger.error(
`Error validating license key: ${key.licenseKeyId}`
);
logger.error(e);
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKeyId,
{
licenseKey: key.licenseKeyId,
licenseKeyEncrypted: key.licenseKeyId,
valid: false
}
);
}
}
if (!foundHostKey && allKeysRes.length) {
logger.debug("No host license key found");
status.isHostLicensed = false;
}
const keys = allKeysRes.map((key) => ({
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
instanceId: decrypt(key.instanceId, this.serverSecret)
}));
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.tier = payload.tier;
cached.numSites = payload.quantity;
cached.iat = new Date(payload.iat * 1000);
// Encrypt the updated token before storing
const encryptedKey = encrypt(
key.licenseKey,
this.serverSecret
);
const encryptedToken = encrypt(
licenseKeyRes,
this.serverSecret
);
await db
.update(licenseKey)
.set({
token: encryptedToken
})
.where(eq(licenseKey.licenseKeyId, encryptedKey));
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 === "HOST") {
status.isLicenseValid = cached.valid;
status.tier = cached.tier;
}
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) {
// Encrypt the license key before storing
const encryptedKey = encrypt(key, this.serverSecret);
const [existingKey] = await db
.select()
.from(licenseKey)
.where(eq(licenseKey.licenseKeyId, encryptedKey))
.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");
}
const encryptedToken = encrypt(licenseKeyRes, this.serverSecret);
// Encrypt the instanceId before storing
const encryptedInstanceId = encrypt(instanceId!, this.serverSecret);
// Store the license key in the database
await db.insert(licenseKey).values({
licenseKeyId: encryptedKey,
token: encryptedToken,
instanceId: encryptedInstanceId
});
} 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> {
// Decrypt the instanceIds before sending to the server
const decryptedKeys = keys.map((key) => ({
licenseKey: key.licenseKey,
instanceId: key.instanceId
? decrypt(key.instanceId, this.serverSecret)
: key.instanceId
}));
const response = await fetch(this.validationServerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKeys: decryptedKeys,
ephemeralKey: this.ephemeralKey,
instanceName: this.hostId
})
});
const data = await response.json();
return data as ValidateLicenseAPIResponse;
}
}
await setHostMeta();
@@ -483,6 +50,6 @@ if (!info) {
throw new Error("Host information not found");
}
export const license = new License(info.hostMetaId);
export const license = new License(info);
export default license;

View File

@@ -21,7 +21,6 @@ export * from "./verifyIsLoggedInUser";
export * from "./verifyIsLoggedInUser";
export * from "./verifyClientAccess";
export * from "./integration";
export * from "./verifyValidLicense";
export * from "./verifyUserHasAction";
export * from "./verifyApiKeyAccess";
export * from "./verifyDomainAccess";

View File

@@ -12,11 +12,8 @@
*/
import { z } from "zod";
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import { db } from "@server/db";
import { SupporterKey, supporterKey } from "@server/db";
import { eq } from "drizzle-orm";
import { license } from "@server/license/license";
import { __DIRNAME } from "@server/lib/consts";
import { SupporterKey } from "@server/db";
import { fromError } from "zod-validation-error";
import {
privateConfigSchema,
@@ -143,7 +140,8 @@ export class PrivateConfig {
process.env.S3_BUCKET = parsedPrivateConfig.stripe.s3Bucket;
}
if (parsedPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH = parsedPrivateConfig.stripe.localFilePath;
process.env.LOCAL_FILE_PATH =
parsedPrivateConfig.stripe.localFilePath;
}
if (parsedPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = parsedPrivateConfig.stripe.s3Region;

View File

@@ -36,6 +36,7 @@ export const privateConfigSchema = z
.pipe(z.string().min(8)),
resend_api_key: z.string().optional(),
reo_client_id: z.string().optional(),
fossorial_api_key: z.string().optional()
}).optional().default({
encryption_key_path: "./config/encryption.pem"
}),
@@ -164,6 +165,9 @@ export function readPrivateConfigFile() {
const loadConfig = (configPath: string) => {
try {
const yamlContent = fs.readFileSync(configPath, "utf8");
if (yamlContent.trim() === "") {
return {};
}
const config = yaml.load(yamlContent);
return config;
} catch (error) {
@@ -176,7 +180,7 @@ export function readPrivateConfigFile() {
}
};
let environment: any;
let environment: any = {};
if (fs.existsSync(privateConfigFilePath1)) {
environment = loadConfig(privateConfigFilePath1);
}

View File

@@ -0,0 +1,459 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, HostMeta } from "@server/db";
import { hostMeta, licenseKey } from "@server/db";
import logger from "@server/logger";
import NodeCache from "node-cache";
import { validateJWT } from "./licenseJwt";
import { eq } from "drizzle-orm";
import moment from "moment";
import { encrypt, decrypt } from "@server/lib/crypto";
import {
LicenseKeyCache,
LicenseKeyTier,
LicenseKeyType,
LicenseStatus
} from "@server/license/license";
import { setHostMeta } from "@server/lib/hostMeta";
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: LicenseKeyType;
tier: LicenseKeyTier;
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 serverBaseUrl = "https://api.fossorial.io";
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
private statusCache = new NodeCache({ stdTTL: this.phoneHomeInterval });
private licenseKeyCache = new NodeCache();
private statusKey = "status";
private serverSecret!: string;
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 hostMeta: HostMeta) {
setInterval(
async () => {
await this.check();
},
1000 * 60 * 60
);
}
public listKeys(): LicenseKeyCache[] {
const keys = this.licenseKeyCache.keys();
return keys.map((key) => {
return this.licenseKeyCache.get<LicenseKeyCache>(key)!;
});
}
public setServerSecret(secret: string) {
this.serverSecret = secret;
}
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> {
const status: LicenseStatus = {
hostId: this.hostMeta.hostMetaId,
isHostLicensed: true,
isLicenseValid: false
};
try {
if (this.statusCache.has(this.statusKey)) {
const res = this.statusCache.get("status") as LicenseStatus;
return res;
}
// Invalidate all
this.licenseKeyCache.flushAll();
const allKeysRes = await db.select().from(licenseKey);
if (allKeysRes.length === 0) {
status.isHostLicensed = false;
return status;
}
let foundHostKey = false;
// Validate stored license keys
for (const key of allKeysRes) {
try {
// Decrypt the license key and token
const decryptedKey = decrypt(
key.licenseKeyId,
this.serverSecret
);
const decryptedToken = decrypt(
key.token,
this.serverSecret
);
const payload = validateJWT<TokenPayload>(
decryptedToken,
this.publicKey
);
this.licenseKeyCache.set<LicenseKeyCache>(decryptedKey, {
licenseKey: decryptedKey,
licenseKeyEncrypted: key.licenseKeyId,
valid: payload.valid,
type: payload.type,
tier: payload.tier,
iat: new Date(payload.iat * 1000),
terminateAt: new Date(payload.terminateAt)
});
if (payload.type === "host") {
foundHostKey = true;
}
} catch (e) {
logger.error(
`Error validating license key: ${key.licenseKeyId}`
);
logger.error(e);
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKeyId,
{
licenseKey: key.licenseKeyId,
licenseKeyEncrypted: key.licenseKeyId,
valid: false
}
);
}
}
if (!foundHostKey && allKeysRes.length) {
logger.debug("No host license key found");
status.isHostLicensed = false;
}
const keys = allKeysRes.map((key) => ({
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
instanceId: decrypt(key.instanceId, this.serverSecret)
}));
let apiResponse: ValidateLicenseAPIResponse | undefined;
try {
// Phone home to validate license keys
apiResponse = await this.phoneHome(keys, false);
if (!apiResponse?.success) {
throw new Error(apiResponse?.error);
}
} catch (e) {
logger.error("Error communicating with license server:");
logger.error(e);
}
// 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.tier = payload.tier;
cached.iat = new Date(payload.iat * 1000);
// Encrypt the updated token before storing
const encryptedKey = encrypt(
key.licenseKey,
this.serverSecret
);
const encryptedToken = encrypt(
licenseKeyRes,
this.serverSecret
);
await db
.update(licenseKey)
.set({
token: encryptedToken
})
.where(eq(licenseKey.licenseKeyId, encryptedKey));
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
)!;
if (cached.type === "host") {
status.isLicenseValid = cached.valid;
status.tier = cached.tier;
}
if (!cached.valid) {
continue;
}
}
} catch (error) {
logger.error("Error checking license status:");
logger.error(error);
}
this.statusCache.set(this.statusKey, status);
return status;
}
public async activateLicenseKey(key: string) {
// Encrypt the license key before storing
const encryptedKey = encrypt(key, this.serverSecret);
const [existingKey] = await db
.select()
.from(licenseKey)
.where(eq(licenseKey.licenseKeyId, encryptedKey))
.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.hostMeta.hostMetaId
})
});
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");
}
logger.debug("Activated license key, instance ID:", {
instanceId: response.data.instanceId
});
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, false);
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");
}
const encryptedToken = encrypt(licenseKeyRes, this.serverSecret);
// Encrypt the instanceId before storing
const encryptedInstanceId = encrypt(instanceId!, this.serverSecret);
// Store the license key in the database
await db.insert(licenseKey).values({
licenseKeyId: encryptedKey,
token: encryptedToken,
instanceId: encryptedInstanceId
});
} 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;
}[],
doDecrypt = true
): Promise<ValidateLicenseAPIResponse> {
// Decrypt the instanceIds before sending to the server
const decryptedKeys = keys.map((key) => ({
licenseKey: key.licenseKey,
instanceId:
key.instanceId && doDecrypt
? decrypt(key.instanceId, this.serverSecret)
: key.instanceId
}));
const response = await fetch(this.validationServerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKeys: decryptedKeys,
instanceName: this.hostMeta.hostMetaId
})
});
const data = await response.json();
return data as ValidateLicenseAPIResponse;
}
}
await setHostMeta();
const [info] = await db.select().from(hostMeta).limit(1);
if (!info) {
throw new Error("Host information not found");
}
export const license = new License(info);
export default license;

View File

@@ -1,3 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import * as crypto from "crypto";
/**

View File

@@ -1,7 +1,8 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import license from "@server/license/license";
import license from "#private/license/license";
import { build } from "@server/build";
export async function verifyValidLicense(
req: Request,
@@ -9,6 +10,10 @@ export async function verifyValidLicense(
next: NextFunction
) {
try {
if (build !== "saas") {
return next();
}
const unlocked = await license.isUnlocked();
if (!unlocked) {
return next(

View File

@@ -19,9 +19,16 @@ import * as loginPage from "#private/routers/loginPage";
import * as orgIdp from "#private/routers/orgIdp";
import * as domain from "#private/routers/domain";
import * as auth from "#private/routers/auth";
import * as license from "#private/routers/license";
import * as generateLicense from "./generatedLicense";
import { Router } from "express";
import { verifyOrgAccess, verifySessionUserMiddleware, verifyUserHasAction } from "@server/middlewares";
import {
verifyOrgAccess,
verifyUserHasAction,
verifyUserIsOrgOwner,
verifyUserIsServerAdmin
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
verifyCertificateAccess,
@@ -33,28 +40,19 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { unauthenticated as ua, authenticated as a } from "@server/routers/external";
import {
unauthenticated as ua,
authenticated as a
} from "@server/routers/external";
import { verifyValidLicense } from "../middlewares/verifyValidLicense";
import { build } from "@server/build";
export const authenticated = a;
export const unauthenticated = ua;
unauthenticated.post(
"/quick-start",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => req.path,
handler: (req, res, next) => {
const message = `We're too busy right now. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
},
store: createStore()
}),
auth.quickStart
);
unauthenticated.post(
"/remote-exit-node/quick-start",
verifyValidLicense,
rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
@@ -68,9 +66,9 @@ unauthenticated.post(
remoteExitNode.quickStartRemoteExitNode
);
authenticated.put(
"/org/:orgId/idp/oidc",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createIdp),
orgIdp.createOrgOidcIdp
@@ -78,6 +76,7 @@ authenticated.put(
authenticated.post(
"/org/:orgId/idp/:idpId/oidc",
verifyValidLicense,
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.updateIdp),
@@ -86,6 +85,7 @@ authenticated.post(
authenticated.delete(
"/org/:orgId/idp/:idpId",
verifyValidLicense,
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.deleteIdp),
@@ -94,6 +94,7 @@ authenticated.delete(
authenticated.get(
"/org/:orgId/idp/:idpId",
verifyValidLicense,
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.getIdp),
@@ -102,6 +103,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/idp",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listIdps),
orgIdp.listOrgIdps
@@ -111,6 +113,7 @@ authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyValidLicense,
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
@@ -119,49 +122,87 @@ authenticated.get(
authenticated.post(
"/org/:orgId/certificate/:certId/restart",
verifyValidLicense,
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.restartCertificate),
certificates.restartCertificate
);
authenticated.post(
if (build === "saas") {
unauthenticated.post(
"/quick-start",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => req.path,
handler: (req, res, next) => {
const message = `We're too busy right now. Please try again later.`;
return next(
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
);
},
store: createStore()
}),
auth.quickStart
);
authenticated.post(
"/org/:orgId/billing/create-checkout-session",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.createCheckoutSession
);
);
authenticated.post(
authenticated.post(
"/org/:orgId/billing/create-portal-session",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.createPortalSession
);
);
authenticated.get(
authenticated.get(
"/org/:orgId/billing/subscription",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.getOrgSubscription
);
);
authenticated.get(
authenticated.get(
"/org/:orgId/billing/usage",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.getOrgUsage
);
);
authenticated.get("/domain/namespaces", domain.listDomainNamespaces);
authenticated.get(
"/org/:orgId/license",
verifyOrgAccess,
generateLicense.listSaasLicenseKeys
);
authenticated.put(
"/org/:orgId/license",
verifyOrgAccess,
generateLicense.generateNewLicense
);
}
authenticated.get(
"/domain/namespaces",
verifyValidLicense,
domain.listDomainNamespaces
);
authenticated.get(
"/domain/check-namespace-availability",
verifyValidLicense,
domain.checkDomainNamespaceAvailability
);
authenticated.put(
"/org/:orgId/remote-exit-node",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
remoteExitNode.createRemoteExitNode
@@ -169,6 +210,7 @@ authenticated.put(
authenticated.get(
"/org/:orgId/remote-exit-nodes",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listRemoteExitNode),
remoteExitNode.listRemoteExitNodes
@@ -176,6 +218,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/remote-exit-node/:remoteExitNodeId",
verifyValidLicense,
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
@@ -184,6 +227,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/pick-remote-exit-node-defaults",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
remoteExitNode.pickRemoteExitNodeDefaults
@@ -191,6 +235,7 @@ authenticated.get(
authenticated.delete(
"/org/:orgId/remote-exit-node/:remoteExitNodeId",
verifyValidLicense,
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.deleteRemoteExitNode),
@@ -199,6 +244,7 @@ authenticated.delete(
authenticated.put(
"/org/:orgId/login-page",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createLoginPage),
loginPage.createLoginPage
@@ -206,6 +252,7 @@ authenticated.put(
authenticated.post(
"/org/:orgId/login-page/:loginPageId",
verifyValidLicense,
verifyOrgAccess,
verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.updateLoginPage),
@@ -214,6 +261,7 @@ authenticated.post(
authenticated.delete(
"/org/:orgId/login-page/:loginPageId",
verifyValidLicense,
verifyOrgAccess,
verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.deleteLoginPage),
@@ -222,6 +270,7 @@ authenticated.delete(
authenticated.get(
"/org/:orgId/login-page",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getLoginPage),
loginPage.getLoginPage
@@ -231,6 +280,7 @@ export const authRouter = Router();
authRouter.post(
"/remoteExitNode/get-token",
verifyValidLicense,
rateLimit({
windowMs: 15 * 60 * 1000,
max: 900,
@@ -247,6 +297,7 @@ authRouter.post(
authRouter.post(
"/transfer-session-token",
verifyValidLicense,
rateLimit({
windowMs: 1 * 60 * 1000,
max: 60,
@@ -260,3 +311,27 @@ authRouter.post(
}),
auth.transferSession
);
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
);

View File

@@ -0,0 +1,91 @@
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/response";
import privateConfig from "@server/private/lib/config";
export type NewLicenseKey = {
licenseKey: {
id: number;
instanceName: string | null;
instanceId: string;
licenseKey: string;
tier: string;
type: string;
quantity: number;
isValid: boolean;
updatedAt: string;
createdAt: string;
expiresAt: string;
orgId: string;
};
};
export type GenerateNewLicenseResponse = NewLicenseKey;
async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
try {
const response = await fetch(
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`,
{
method: "PUT",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify(licenseData)
}
);
const data = await response.json();
logger.debug("Fossorial API response:", {data});
return data;
} catch (error) {
console.error("Error creating new license:", error);
throw error;
}
}
export async function generateNewLicense(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const { orgId } = req.params;
if (!orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization ID is required"
)
);
}
logger.debug(`Generating new license for orgId: ${orgId}`);
const licenseData = req.body;
const apiResponse = await createNewLicense(orgId, licenseData);
return sendResponse<GenerateNewLicenseResponse>(res, {
data: apiResponse.data,
success: apiResponse.success,
error: apiResponse.error,
message: apiResponse.message,
status: apiResponse.status
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while generating new license"
)
);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./listGeneratedLicenses";
export * from "./generateNewLicense";

View File

@@ -0,0 +1,83 @@
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/response";
import privateConfig from "@server/private/lib/config";
export type GeneratedLicenseKey = {
instanceName: string | null;
licenseKey: string;
expiresAt: string;
isValid: boolean;
createdAt: string;
tier: string;
type: string;
};
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
async function fetchLicenseKeys(orgId: string): Promise<any> {
try {
const response = await fetch(
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`,
{
method: "GET",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching license keys:", error);
throw error;
}
}
export async function listSaasLicenseKeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const { orgId } = req.params;
if (!orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization ID is required"
)
);
}
const apiResponse = await fetchLicenseKeys(orgId);
const keys: GeneratedLicenseKey[] = apiResponse.data.licenseKeys || [];
return sendResponse<ListGeneratedLicenseKeysResponse>(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 while fetching license keys"
)
);
}
}

View File

@@ -15,6 +15,7 @@ import * as loginPage from "#private/routers/loginPage";
import * as auth from "#private/routers/auth";
import * as orgIdp from "#private/routers/orgIdp";
import * as billing from "#private/routers/billing";
import * as license from "#private/routers/license";
import { Router } from "express";
import { verifySessionUserMiddleware } from "@server/middlewares";
@@ -34,3 +35,5 @@ internalRouter.post(
verifySessionUserMiddleware,
auth.getSessionTransferToken
);
internalRouter.get(`/license/status`, license.getLicenseStatus);

View File

@@ -3,9 +3,10 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import license, { LicenseStatus } from "@server/license/license";
import license from "#private/license/license";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { LicenseStatus } from "@server/license/license";
const bodySchema = z
.object({

View File

@@ -8,9 +8,8 @@ import { fromError } from "zod-validation-error";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import { licenseKey } from "@server/db";
import license, { LicenseStatus } from "@server/license/license";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import license from "#private/license/license";
import { LicenseStatus } from "@server/license/license";
const paramsSchema = z
.object({

View File

@@ -3,7 +3,8 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import license, { LicenseStatus } from "@server/license/license";
import license from "#private/license/license";
import { LicenseStatus } from "@server/license/license";
export type GetLicenseStatusResponse = LicenseStatus;

View File

@@ -3,7 +3,8 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import license, { LicenseKeyCache } from "@server/license/license";
import license from "#private/license/license";
import { LicenseKeyCache } from "@server/license/license";
export type ListLicenseKeysResponse = LicenseKeyCache[];

View File

@@ -3,7 +3,8 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import license, { LicenseStatus } from "@server/license/license";
import license from "#private/license/license";
import { LicenseStatus } from "@server/license/license";
export type RecheckStatusResponse = LicenseStatus;

View File

@@ -13,7 +13,6 @@ import * as siteResource from "./siteResource";
import * as supporterKey from "./supporterKey";
import * as accessToken from "./accessToken";
import * as idp from "./idp";
import * as license from "./license";
import * as apiKeys from "./apiKeys";
import HttpCode from "@server/types/HttpCode";
import {
@@ -710,30 +709,6 @@ authenticated.get(
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
);
authenticated.get(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,

View File

@@ -11,7 +11,6 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db";
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();

View File

@@ -11,7 +11,6 @@ import { idp, idpOidcConfig } from "@server/db";
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({

View File

@@ -5,7 +5,6 @@ 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 * as idp from "@server/routers/idp";
import { proxyToRemote } from "@server/lib/remoteProxy";
import config from "@server/lib/config";
@@ -41,8 +40,6 @@ internalRouter.get(
supporterKey.isSupporterKeyVisible
);
internalRouter.get(`/license/status`, license.getLicenseStatus);
internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp);

View File

@@ -3,12 +3,10 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import privateConfig from "#private/lib/config";
import config from "@server/lib/config";
import { db } from "@server/db";
import { count } from "drizzle-orm";
import { users } from "@server/db";
import license from "@server/license/license";
import { build } from "@server/build";
export type IsSupporterKeyVisibleResponse = {
@@ -29,12 +27,6 @@ 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);
@@ -46,7 +38,7 @@ export async function isSupporterKeyVisible(
}
}
if (build != "oss") {
if (build !== "oss") {
visible = false;
}

View File

@@ -9,7 +9,7 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import SetLastOrgCookie from "@app/components/SetLastOrgCookie";
import PrivateSubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider";
import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider";
import { GetOrgSubscriptionResponse } from "#private/routers/billing/getOrgSubscription";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
@@ -56,7 +56,7 @@ export default async function OrgLayout(props: {
}
let subscriptionStatus = null;
if (build != "oss") {
if (build === "saas") {
try {
const getSubscription = cache(() =>
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
@@ -73,13 +73,13 @@ export default async function OrgLayout(props: {
}
return (
<PrivateSubscriptionStatusProvider
<SubscriptionStatusProvider
subscriptionStatus={subscriptionStatus}
env={env.app.environment}
sandbox_mode={env.app.sandbox_mode}
>
{props.children}
<SetLastOrgCookie orgId={orgId} />
</PrivateSubscriptionStatusProvider>
</SubscriptionStatusProvider>
);
}

View File

@@ -60,13 +60,6 @@ export default async function BillingSettingsPage({
const t = await getTranslations();
const navItems = [
{
title: t('billing'),
href: `/{orgId}/settings/billing`,
},
];
return (
<>
<OrgProvider org={org}>

View File

@@ -45,7 +45,10 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
return (
<>

View File

@@ -0,0 +1,42 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
type LicensesSettingsProps = {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
};
export default async function LicensesSetingsLayoutProps({
children,
params
}: LicensesSettingsProps) {
const { orgId } = await params;
if (build !== "saas") {
redirect(`/${orgId}/settings`);
}
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect(`/`);
}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("saasLicenseKeysSettingsTitle")}
description={t("saasLicenseKeysSettingsDescription")}
/>
{children}
</>
);
}

View File

@@ -0,0 +1,25 @@
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/private/routers/generatedLicense";
import { AxiosResponse } from "axios";
type Props = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function Page({ params }: Props) {
const { orgId } = await params;
let licenseKeys: ListGeneratedLicenseKeysResponse = [];
try {
const data = await internal.get<
AxiosResponse<ListGeneratedLicenseKeysResponse>
>(`/org/${orgId}/license`, await authCookieHeader());
licenseKeys = data.data.data;
} catch {}
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
}

View File

@@ -77,9 +77,10 @@ export default function Page() {
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
const [selectedOption, setSelectedOption] = useState<string | null>("internal");
const [selectedOption, setSelectedOption] = useState<string | null>(
"internal"
);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
@@ -204,7 +205,13 @@ export default function Page() {
googleAzureForm.reset();
genericOidcForm.reset();
}
}, [selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm]);
}, [
selectedOption,
env.email.emailEnabled,
internalForm,
googleAzureForm,
genericOidcForm
]);
useEffect(() => {
if (!selectedOption) {
@@ -232,7 +239,7 @@ export default function Page() {
}
async function fetchIdps() {
if (build === "saas" && !subscribed) {
if (build === "saas" && !subscription?.subscribed) {
return;
}
@@ -345,7 +352,9 @@ export default function Page() {
async function onSubmitGoogleAzure(
values: z.infer<typeof googleAzureFormSchema>
) {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption
);
if (!selectedUserOption?.idpId) return;
setLoading(true);
@@ -385,7 +394,9 @@ export default function Page() {
async function onSubmitGenericOidc(
values: z.infer<typeof genericOidcFormSchema>
) {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption
);
if (!selectedUserOption?.idpId) return;
setLoading(true);
@@ -675,7 +686,9 @@ export default function Page() {
</>
)}
{selectedOption && selectedOption !== "internal" && dataLoaded && (
{selectedOption &&
selectedOption !== "internal" &&
dataLoaded && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -689,8 +702,18 @@ export default function Page() {
<SettingsSectionForm>
{/* Google/Azure Form */}
{(() => {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure";
const selectedUserOption =
userOptions.find(
(opt) =>
opt.id ===
selectedOption
);
return (
selectedUserOption?.variant ===
"google" ||
selectedUserOption?.variant ===
"azure"
);
})() && (
<Form {...googleAzureForm}>
<form
@@ -701,7 +724,9 @@ export default function Page() {
id="create-user-form"
>
<FormField
control={googleAzureForm.control}
control={
googleAzureForm.control
}
name="email"
render={({ field }) => (
<FormItem>
@@ -719,12 +744,16 @@ export default function Page() {
/>
<FormField
control={googleAzureForm.control}
control={
googleAzureForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("nameOptional")}
{t(
"nameOptional"
)}
</FormLabel>
<FormControl>
<Input
@@ -737,7 +766,9 @@ export default function Page() {
/>
<FormField
control={googleAzureForm.control}
control={
googleAzureForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
@@ -745,24 +776,36 @@ export default function Page() {
{t("role")}
</FormLabel>
<Select
onValueChange={field.onChange}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("accessRoleSelect")}
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
{roles.map(
(
role
) => (
<SelectItem
key={role.roleId}
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
{
role.name
}
</SelectItem>
))}
)
)}
</SelectContent>
</Select>
<FormMessage />
@@ -775,8 +818,18 @@ export default function Page() {
{/* Generic OIDC Form */}
{(() => {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure";
const selectedUserOption =
userOptions.find(
(opt) =>
opt.id ===
selectedOption
);
return (
selectedUserOption?.variant !==
"google" &&
selectedUserOption?.variant !==
"azure"
);
})() && (
<Form {...genericOidcForm}>
<form
@@ -787,12 +840,16 @@ export default function Page() {
id="create-user-form"
>
<FormField
control={genericOidcForm.control}
control={
genericOidcForm.control
}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
{t(
"username"
)}
</FormLabel>
<FormControl>
<Input
@@ -800,7 +857,9 @@ export default function Page() {
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
{t("usernameUniq")}
{t(
"usernameUniq"
)}
</p>
<FormMessage />
</FormItem>
@@ -808,12 +867,16 @@ export default function Page() {
/>
<FormField
control={genericOidcForm.control}
control={
genericOidcForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("emailOptional")}
{t(
"emailOptional"
)}
</FormLabel>
<FormControl>
<Input
@@ -826,12 +889,16 @@ export default function Page() {
/>
<FormField
control={genericOidcForm.control}
control={
genericOidcForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("nameOptional")}
{t(
"nameOptional"
)}
</FormLabel>
<FormControl>
<Input
@@ -844,7 +911,9 @@ export default function Page() {
/>
<FormField
control={genericOidcForm.control}
control={
genericOidcForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
@@ -852,24 +921,36 @@ export default function Page() {
{t("role")}
</FormLabel>
<Select
onValueChange={field.onChange}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("accessRoleSelect")}
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
{roles.map(
(
role
) => (
<SelectItem
key={role.roleId}
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
{
role.name
}
</SelectItem>
))}
)
)}
</SelectContent>
</Select>
<FormMessage />

View File

@@ -5,6 +5,7 @@ import { ClientRow } from "../../../../components/ClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import ClientsTable from "../../../../components/ClientsTable";
import { getTranslations } from "next-intl/server";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
@@ -13,6 +14,8 @@ type ClientsPageProps = {
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let clients: ListClientsResponse["clients"] = [];
try {
@@ -48,8 +51,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
return (
<>
<SettingsSectionTitle
title="Manage Clients (beta)"
description="Clients are devices that can connect to your sites"
title={t("manageClients")}
description={t("manageClientsDescription")}
/>
<ClientsTable clients={clientRows} orgId={params.orgId} />

View File

@@ -1,6 +1,8 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import AuthPageSettings, { AuthPageSettingsRef } from "@app/components/private/AuthPageSettings";
import AuthPageSettings, {
AuthPageSettingsRef
} from "@app/components/private/AuthPageSettings";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
@@ -134,7 +136,10 @@ export default function GeneralPage() {
});
// Also save auth page settings if they have unsaved changes
if (build === "saas" && authPageSettingsRef.current?.hasUnsavedChanges()) {
if (
build === "saas" &&
authPageSettingsRef.current?.hasUnsavedChanges()
) {
await authPageSettingsRef.current.saveAuthSettings();
}
@@ -239,7 +244,9 @@ export default function GeneralPage() {
</SettingsSectionBody>
</SettingsSection>
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
{(build === "saas") && (
<AuthPageSettings ref={authPageSettingsRef} />
)}
{/* Save Button */}
<div className="flex justify-end">
@@ -276,7 +283,6 @@ export default function GeneralPage() {
</SettingsSectionFooter>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -99,7 +99,6 @@ export default function ResourceAuthenticationPage() {
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
const [pageLoading, setPageLoading] = useState(true);
@@ -141,8 +140,10 @@ export default function ResourceAuthenticationPage() {
useState(false);
const [loadingRemoveResourcePincode, setLoadingRemoveResourcePincode] =
useState(false);
const [loadingRemoveResourceHeaderAuth, setLoadingRemoveResourceHeaderAuth] =
useState(false);
const [
loadingRemoveResourceHeaderAuth,
setLoadingRemoveResourceHeaderAuth
] = useState(false);
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
@@ -234,7 +235,7 @@ export default function ResourceAuthenticationPage() {
);
if (build === "saas") {
if (subscribed) {
if (subscription?.subscribed) {
setAllIdps(
idpsResponse.data.data.idps.map((idp) => ({
id: idp.idpId,

View File

@@ -0,0 +1,17 @@
import { build } from "@server/build";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
interface LayoutProps {
children: React.ReactNode;
}
export default async function AdminLicenseLayout(props: LayoutProps) {
if (build !== "enterprise") {
redirect(`/admin`);
}
return props.children;
}

View File

@@ -31,7 +31,6 @@ import {
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useRouter } from "next/navigation";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import {
SettingsContainer,
@@ -43,14 +42,10 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Badge } from "@app/components/ui/badge";
import { Check, Heart, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { Check, Heart, InfoIcon } 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";
import Link from "next/link";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
@@ -70,13 +65,11 @@ export default function LicensePage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedLicenseKey, setSelectedLicenseKey] =
useState<LicenseKeyCache | 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");
const [purchaseMode, setPurchaseMode] = useState<"license">("license");
// Separate loading states for different actions
const [isInitialLoading, setIsInitialLoading] = useState(true);
@@ -90,10 +83,10 @@ export default function LicensePage() {
const formSchema = z.object({
licenseKey: z
.string()
.nonempty({ message: t('licenseKeyRequired') })
.nonempty({ message: t("licenseKeyRequired") })
.max(255),
agreeToTerms: z.boolean().refine((val) => val === true, {
message: t('licenseTermsAgree')
message: t("licenseTermsAgree")
})
});
@@ -122,7 +115,7 @@ export default function LicensePage() {
);
const keys = response.data.data;
setRows(keys);
const hostKey = keys.find((key) => key.type === "HOST");
const hostKey = keys.find((key) => key.type === "host");
if (hostKey) {
setHostLicense(hostKey.licenseKey);
} else {
@@ -130,10 +123,10 @@ export default function LicensePage() {
}
} catch (e) {
toast({
title: t('licenseErrorKeyLoad'),
title: t("licenseErrorKeyLoad"),
description: formatAxiosError(
e,
t('licenseErrorKeyLoadDescription')
t("licenseErrorKeyLoadDescription")
)
});
}
@@ -149,16 +142,16 @@ export default function LicensePage() {
}
await loadLicenseKeys();
toast({
title: t('licenseKeyDeleted'),
description: t('licenseKeyDeletedDescription')
title: t("licenseKeyDeleted"),
description: t("licenseKeyDeletedDescription")
});
setIsDeleteModalOpen(false);
} catch (e) {
toast({
title: t('licenseErrorKeyDelete'),
title: t("licenseErrorKeyDelete"),
description: formatAxiosError(
e,
t('licenseErrorKeyDeleteDescription')
t("licenseErrorKeyDeleteDescription")
)
});
} finally {
@@ -175,15 +168,15 @@ export default function LicensePage() {
}
await loadLicenseKeys();
toast({
title: t('licenseErrorKeyRechecked'),
description: t('licenseErrorKeyRecheckedDescription')
title: t("licenseErrorKeyRechecked"),
description: t("licenseErrorKeyRecheckedDescription")
});
} catch (e) {
toast({
title: t('licenseErrorKeyRecheck'),
title: t("licenseErrorKeyRecheck"),
description: formatAxiosError(
e,
t('licenseErrorKeyRecheckDescription')
t("licenseErrorKeyRecheckDescription")
)
});
} finally {
@@ -202,8 +195,8 @@ export default function LicensePage() {
}
toast({
title: t('licenseKeyActivated'),
description: t('licenseKeyActivatedDescription')
title: t("licenseKeyActivated"),
description: t("licenseKeyActivatedDescription")
});
setIsCreateModalOpen(false);
@@ -212,10 +205,10 @@ export default function LicensePage() {
} catch (e) {
toast({
variant: "destructive",
title: t('licenseErrorKeyActivate'),
title: t("licenseErrorKeyActivate"),
description: formatAxiosError(
e,
t('licenseErrorKeyActivateDescription')
t("licenseErrorKeyActivateDescription")
)
});
} finally {
@@ -246,9 +239,9 @@ export default function LicensePage() {
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('licenseActivateKey')}</CredenzaTitle>
<CredenzaTitle>{t("licenseActivateKey")}</CredenzaTitle>
<CredenzaDescription>
{t('licenseActivateKeyDescription')}
{t("licenseActivateKeyDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -263,7 +256,9 @@ export default function LicensePage() {
name="licenseKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('licenseKey')}</FormLabel>
<FormLabel>
{t("licenseKey")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -286,16 +281,7 @@ export default function LicensePage() {
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t('licenseAgreement')}
{/* <br /> */}
{/* <Link */}
{/* href="https://fossorial.io/license.html" */}
{/* target="_blank" */}
{/* rel="noopener noreferrer" */}
{/* className="text-primary hover:underline" */}
{/* > */}
{/* {t('fossorialLicense')} */}
{/* </Link> */}
{t("licenseAgreement")}
</FormLabel>
<FormMessage />
</div>
@@ -307,7 +293,7 @@ export default function LicensePage() {
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -315,7 +301,7 @@ export default function LicensePage() {
loading={isActivatingLicense}
disabled={isActivatingLicense}
>
{t('licenseActivate')}
{t("licenseActivate")}
</Button>
</CredenzaFooter>
</CredenzaContent>
@@ -331,49 +317,48 @@ export default function LicensePage() {
dialog={
<div className="space-y-4">
<p>
{t('licenseQuestionRemove', {selectedKey: obfuscateLicenseKey(selectedLicenseKey.licenseKey)})}
{t("licenseQuestionRemove", {
selectedKey: obfuscateLicenseKey(
selectedLicenseKey.licenseKey
)
})}
</p>
<p>
<b>
{t('licenseMessageRemove')}
</b>
</p>
<p>
{t('licenseMessageConfirm')}
<b>{t("licenseMessageRemove")}</b>
</p>
<p>{t("licenseMessageConfirm")}</p>
</div>
}
buttonText={t('licenseKeyDeleteConfirm')}
buttonText={t("licenseKeyDeleteConfirm")}
onConfirm={async () =>
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
}
string={selectedLicenseKey.licenseKey}
title={t('licenseKeyDelete')}
title={t("licenseKeyDelete")}
/>
)}
<SettingsSectionTitle
title={t('licenseTitle')}
description={t('licenseTitleDescription')}
title={t("licenseTitle")}
description={t("licenseTitleDescription")}
/>
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('licenseAbout')}
</AlertTitle>
<AlertDescription>
{t('licenseAboutDescription')}
</AlertDescription>
</Alert>
{/* <Alert variant="neutral" className="mb-6"> */}
{/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold"> */}
{/* {t("licenseAbout")} */}
{/* </AlertTitle> */}
{/* <AlertDescription> */}
{/* {t("licenseAboutDescription")} */}
{/* </AlertDescription> */}
{/* </Alert> */}
<SettingsContainer>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t('licenseHost')}</SSTitle>
<SSTitle>{t("licenseHost")}</SSTitle>
<SettingsSectionDescription>
{t('licenseHostDescription')}
{t("licenseHostDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
@@ -382,34 +367,19 @@ export default function LicensePage() {
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{licenseStatus?.tier ===
"PROFESSIONAL"
? t('licenseTierCommercial')
: licenseStatus?.tier ===
"ENTERPRISE"
? t('licenseTierCommercial')
: t('licensed')}
{t("licensed")}
</div>
</div>
) : (
<div className="space-y-2">
{supporterStatus?.visible ? (
<div className="text-2xl">
{t('communityEdition')}
</div>
) : (
<div className="text-2xl flex items-center gap-2 text-pink-500">
<Heart />
{t('communityEdition')}
</div>
)}
{t("unlicensed")}
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('hostId')}
{t("hostId")}
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
@@ -417,7 +387,7 @@ export default function LicensePage() {
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('licenseKey')}
{t("licenseKey")}
</div>
<CopyTextBox
text={hostLicense}
@@ -435,83 +405,10 @@ export default function LicensePage() {
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t('licenseReckeckAll')}
{t("licenseReckeckAll")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t('licenseSiteUsage')}</SSTitle>
<SettingsSectionDescription>
{t('licenseSiteUsageDecsription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-2xl">
{t('licenseSitesUsed', {count: licenseStatus?.usedSites || 0})}
</div>
</div>
{!licenseStatus?.isHostLicensed && (
<p className="text-sm text-muted-foreground">
{t('licenseNoSiteLimit')}
</p>
)}
{licenseStatus?.maxSites && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('licenseSitesUsedMax', {usedSites: licenseStatus.usedSites || 0, maxSites: licenseStatus.maxSites})}
</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 */}
{/* onClick={() => { */}
{/* setPurchaseMode("license"); */}
{/* setIsPurchaseModalOpen(true); */}
{/* }} */}
{/* > */}
{/* {t('licensePurchase')} */}
{/* </Button> */}
{/* </> */}
{/* ) : ( */}
{/* <> */}
{/* <Button */}
{/* variant="outline" */}
{/* onClick={() => { */}
{/* setPurchaseMode("additional-sites"); */}
{/* setIsPurchaseModalOpen(true); */}
{/* }} */}
{/* > */}
{/* {t('licensePurchaseSites')} */}
{/* </Button> */}
{/* </> */}
{/* )} */}
{/* </SettingsSectionFooter> */}
</SettingsSection>
</SettingsSectionGrid>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {

View File

@@ -1,180 +0,0 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionTitle as SectionTitle,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Alert } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
Shield,
Zap,
RefreshCw,
Activity,
Wrench,
CheckCircle,
ExternalLink
} from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function ManagedPage() {
const t = useTranslations();
return (
<>
<SettingsSectionTitle
title={t("managedSelfHosted.title")}
description={t("managedSelfHosted.description")}
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionBody>
<p className="mb-4">
<strong>{t("managedSelfHosted.introTitle")}</strong>{" "}
{t("managedSelfHosted.introDescription")}
</p>
<p className="mb-6">
{t("managedSelfHosted.introDetail")}
</p>
<div className="grid gap-4 md:grid-cols-2 py-4">
<div className="space-y-3">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitSimplerOperations.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitSimplerOperations.description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitAutomaticUpdates.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitAutomaticUpdates.description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitLessMaintenance.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitLessMaintenance.description"
)}
</p>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitCloudFailover.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitCloudFailover.description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitHighAvailability.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitHighAvailability.description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitFutureEnhancements.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitFutureEnhancements.description"
)}
</p>
</div>
</div>
</div>
</div>
<Alert
variant="neutral"
className="flex items-center gap-1"
>
{t("managedSelfHosted.docsAlert.text")}{" "}
<Link
href="https://docs.digpangolin.com/manage/managed"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1"
>
{t("managedSelfHosted.docsAlert.documentation")}
<ExternalLink className="w-4 h-4" />
</Link>
.
</Alert>
</SettingsSectionBody>
<SettingsSectionFooter>
<Link
href="https://docs.digpangolin.com/self-host/convert-managed"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1"
>
<Button>
{t("managedSelfHosted.convertButton")}
</Button>
</Link>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);
}

View File

@@ -74,6 +74,7 @@ export default async function OrgAuthPage(props: {
}
let subscriptionStatus: GetOrgTierResponse | null = null;
if (build === "saas") {
try {
const getSubscription = cache(() =>
priv.get<AxiosResponse<GetOrgTierResponse>>(
@@ -83,7 +84,11 @@ export default async function OrgAuthPage(props: {
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
}
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
if (build === "saas" && !subscribed) {
redirect(env.app.dashboardUrl);

View File

@@ -1,15 +1,5 @@
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import { AxiosResponse } from "axios";
import { ExternalLink } from "lucide-react";
import { Metadata } from "next";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -21,19 +11,6 @@ type AuthLayoutProps = {
};
export default async function AuthLayout({ children }: AuthLayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const t = await getTranslations();
const hideFooter = true;
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
const licenseStatus = licenseStatusRes.data.data;
return (
<div className="h-full flex flex-col">
<div className="flex justify-end items-center p-3 space-x-2">
@@ -43,49 +20,6 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!(
hideFooter || (
licenseStatus.isHostLicensed &&
licenseStatus.isLicenseValid)
) && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<a
href="https://fossorial.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Fossorial</span>
<ExternalLink className="w-3 h-3" />
</a>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("communityEdition")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
</div>
</footer>
)}
</div>
);
}

View File

@@ -73,7 +73,10 @@ export default async function ResourceAuthPage(props: {
subscriptionStatus = subRes.data.data;
} catch {}
}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
const allHeaders = await headers();
const host = allHeaders.get("host");
@@ -207,7 +210,12 @@ export default async function ResourceAuthPage(props: {
})) as LoginFormIDP[];
}
if (!userIsUnauthorized && isSSOOnly && authInfo.skipToIdpId && authInfo.skipToIdpId !== null) {
if (
!userIsUnauthorized &&
isSSOOnly &&
authInfo.skipToIdpId &&
authInfo.skipToIdpId !== null
) {
const idp = loginIdps.find((idp) => idp.idpId === authInfo.skipToIdpId);
if (idp) {
return (

View File

@@ -11,12 +11,13 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import { GetLicenseStatusResponse } from "#private/routers/license";
import LicenseViolation from "@app/components/LicenseViolation";
import { cache } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale } from "next-intl/server";
import { Toaster } from "@app/components/ui/toaster";
import { build } from "@server/build";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -57,13 +58,22 @@ export default async function RootLayout({
supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier;
let licenseStatus: GetLicenseStatusResponse;
if (build === "enterprise") {
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
const licenseStatus = licenseStatusRes.data.data;
licenseStatus = licenseStatusRes.data.data;
} else {
licenseStatus = {
isHostLicensed: false,
isLicenseValid: false,
hostId: ""
};
}
return (
<html suppressHydrationWarning lang={locale}>

View File

@@ -54,7 +54,8 @@ export const orgNavSections = (
{
title: "sidebarClients",
href: "/{orgId}/settings/clients",
icon: <MonitorUp className="h-4 w-4" />
icon: <MonitorUp className="h-4 w-4" />,
isBeta: true
}
]
: []),
@@ -63,7 +64,8 @@ export const orgNavSections = (
{
title: "sidebarRemoteExitNodes",
href: "/{orgId}/settings/remote-exit-nodes",
icon: <Server className="h-4 w-4" />
icon: <Server className="h-4 w-4" />,
showEE: true
}
]
: []),
@@ -97,7 +99,8 @@ export const orgNavSections = (
{
title: "sidebarIdentityProviders",
href: "/{orgId}/settings/idp",
icon: <Fingerprint className="h-4 w-4" />
icon: <Fingerprint className="h-4 w-4" />,
showEE: true
}
]
: []),
@@ -116,15 +119,6 @@ export const orgNavSections = (
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />
},
...(build == "saas"
? [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <TicketCheck className="h-4 w-4" />
}
]
: []),
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
@@ -138,15 +132,6 @@ export const adminNavSections: SidebarNavSection[] = [
{
heading: "Admin",
items: [
...(build == "oss"
? [
{
title: "managedSelfhosted",
href: "/admin/managed",
icon: <Zap className="h-4 w-4" />
}
]
: []),
{
title: "sidebarAllUsers",
href: "/admin/users",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
"use client";
import { useTranslations } from "next-intl";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "./ui/button";
import { ArrowUpDown } from "lucide-react";
import CopyToClipboard from "./CopyToClipboard";
import { Badge } from "./ui/badge";
import moment from "moment";
import { DataTable } from "./ui/data-table";
import { GeneratedLicenseKey } from "@server/private/routers/generatedLicense";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import { GenerateNewLicenseResponse } from "@server/private/routers/generatedLicense/generateNewLicense";
import GenerateLicenseKeyForm from "./GenerateLicenseKeyForm";
type GnerateLicenseKeysTableProps = {
licenseKeys: GeneratedLicenseKey[];
orgId: string;
};
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 GenerateLicenseKeysTable({
licenseKeys,
orgId
}: GnerateLicenseKeysTableProps) {
const t = useTranslations();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isRefreshing, setIsRefreshing] = useState(false);
const [showGenerateForm, setShowGenerateForm] = useState(false);
const handleLicenseGenerated = () => {
// Refresh the data after license is generated
refreshData();
};
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const columns: ColumnDef<GeneratedLicenseKey>[] = [
{
accessorKey: "licenseKey",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("licenseKey")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const licenseKey = row.original.licenseKey;
return (
<CopyToClipboard
text={licenseKey}
displayText={obfuscateLicenseKey(licenseKey)}
/>
);
}
},
{
accessorKey: "instanceName",
cell: ({ row }) => {
return row.original.instanceName || "-";
}
},
{
accessorKey: "valid",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("valid")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return row.original.isValid ? (
<Badge variant="green">{t("yes")}</Badge>
) : (
<Badge variant="red">{t("no")}</Badge>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const tier = row.original.tier;
return tier === "enterprise"
? t("licenseTierEnterprise")
: t("licenseTierPersonal");
}
},
{
accessorKey: "terminateAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("licenseTableValidUntil")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const termianteAt = row.original.expiresAt;
return moment(termianteAt).format("lll");
}
}
];
return (
<>
<DataTable
columns={columns}
data={licenseKeys}
persistPageSize="licenseKeys-table"
title={t("licenseKeys")}
searchPlaceholder={t("licenseKeySearch")}
searchColumn="licenseKey"
onRefresh={refreshData}
isRefreshing={isRefreshing}
addButtonText={t("generateLicenseKey")}
onAdd={() => {
setShowGenerateForm(true);
}}
/>
<GenerateLicenseKeyForm
open={showGenerateForm}
setOpen={setShowGenerateForm}
orgId={orgId}
onGenerated={handleLicenseGenerated}
/>
</>
);
}

View File

@@ -6,7 +6,15 @@ import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
import {
ExternalLink,
Server,
BookOpenText,
Zap,
CreditCard,
FileText,
TicketCheck
} from "lucide-react";
import { FaDiscord, FaGithub } from "react-icons/fa";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -22,6 +30,7 @@ import {
TooltipTrigger
} from "@app/components/ui/tooltip";
import { build } from "@server/build";
import SidebarLicenseButton from "./SidebarLicenseButton";
interface LayoutSidebarProps {
orgId?: string;
@@ -119,8 +128,78 @@ export function LayoutSidebar({
/>
</div>
</div>
<div className="p-4 space-y-4 shrink-0">
{build === "saas" && (
<div className="mb-3 pt-4">
<div className="space-y-1">
<Link
href={`/${orgId}/settings/billing`}
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("sidebarBilling")
: undefined
}
>
<span
className={cn(
"flex-shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<CreditCard className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("sidebarBilling")}</span>
)}
</Link>
<Link
href={`/${orgId}/settings/license`}
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("sidebarEnterpriseLicenses")
: undefined
}
>
<span
className={cn(
"flex-shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<TicketCheck className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("sidebarEnterpriseLicenses")}</span>
)}
</Link>
</div>
</div>
)}
{build === "enterprise" && (
<div className="mb-3">
<SidebarLicenseButton
isCollapsed={isSidebarCollapsed}
/>
</div>
)}
{build === "oss" && (
<div className="mb-3">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
</div>
)}
{!isSidebarCollapsed && (
<div className="space-y-2">
{loadFooterLinks() ? (
@@ -159,9 +238,9 @@ export function LayoutSidebar({
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
{build === "oss"
? t("communityEdition")
: t("commercialEdition")}
: t("enterpriseEdition")}
<FaGithub size={12} />
</Link>
</div>

View File

@@ -6,9 +6,9 @@ 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";
import { useTranslations } from "next-intl";
import moment from "moment";
type LicenseKeysDataTableProps = {
licenseKeys: LicenseKeyCache[];
@@ -28,7 +28,6 @@ export function LicenseKeysDataTable({
onDelete,
onCreate
}: LicenseKeysDataTableProps) {
const t = useTranslations();
const columns: ColumnDef<LicenseKeyCache>[] = [
@@ -42,7 +41,7 @@ export function LicenseKeysDataTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('licenseKey')}
{t("licenseKey")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -67,13 +66,17 @@ export function LicenseKeysDataTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('valid')}
{t("valid")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return row.original.valid ? t('yes') : t('no');
return row.original.valid ? (
<Badge variant="green">{t("yes")}</Badge>
) : (
<Badge variant="red">{t("no")}</Badge>
);
}
},
{
@@ -86,23 +89,20 @@ export function LicenseKeysDataTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('type')}
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
const label =
type === "SITES" ? t('sitesAdditional') : t('licenseHost');
const variant = type === "SITES" ? "secondary" : "default";
return row.original.valid ? (
<Badge variant={variant}>{label}</Badge>
) : null;
const tier = row.original.tier;
tier === "enterprise"
? t("licenseTierEnterprise")
: t("licenseTierPersonal");
}
},
{
accessorKey: "numSites",
accessorKey: "terminateAt",
header: ({ column }) => {
return (
<Button
@@ -111,10 +111,14 @@ export function LicenseKeysDataTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('numberOfSites')}
{t("licenseTableValidUntil")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const termianteAt = row.original.terminateAt;
return moment(termianteAt).format("lll");
}
},
{
@@ -125,7 +129,7 @@ export function LicenseKeysDataTable({
variant="secondary"
onClick={() => onDelete(row.original)}
>
{t('delete')}
{t("delete")}
</Button>
</div>
)
@@ -137,11 +141,11 @@ export function LicenseKeysDataTable({
columns={columns}
data={licenseKeys}
persistPageSize="licenseKeys-table"
title={t('licenseKeys')}
searchPlaceholder={t('licenseKeySearch')}
title={t("licenseKeys")}
searchPlaceholder={t("licenseKeySearch")}
searchColumn="licenseKey"
onAdd={onCreate}
addButtonText={t('licenseKeyAdd')}
addButtonText={t("licenseKeyAdd")}
/>
);
}

View File

@@ -32,29 +32,5 @@ export default function LicenseViolation() {
);
}
// 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">
<div className="flex justify-between items-center">
<p>
{t('componentsLicenseViolation', {usedSites: licenseStatus.usedSites, maxSites: licenseStatus.maxSites})}
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
{t('dismiss')}
</Button>
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,54 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { Button } from "./ui/button";
import { TicketCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import Link from "next/link";
interface SidebarLicenseButtonProps {
isCollapsed?: boolean;
}
export default function SidebarLicenseButton({
isCollapsed = false
}: SidebarLicenseButtonProps) {
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const t = useTranslations();
return (
<>
{!licenseStatus?.isHostLicensed ? (
isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href="https://docs.digpangolin.com/">
<Button size="icon" className="w-8 h-8">
<TicketCheck className="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Enable Enterprise License
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link href="https://docs.digpangolin.com/">
<Button size="sm" className="gap-2 w-full">
Enable Enterprise License
</Button>
</Link>
)
) : null}
</>
);
}

View File

@@ -14,12 +14,14 @@ import {
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { build } from "@server/build";
export type SidebarNavItem = {
href: string;
title: string;
icon?: React.ReactNode;
showProfessional?: boolean;
showEE?: boolean;
isBeta?: boolean;
};
export type SidebarNavSection = {
@@ -71,7 +73,7 @@ export function SidebarNav({
isDisabled: boolean
) => {
const tooltipText =
item.showProfessional && !isUnlocked()
item.showEE && !isUnlocked()
? `${t(item.title)} (${t("licenseBadge")})`
: t(item.title);
@@ -106,8 +108,21 @@ export function SidebarNav({
{!isCollapsed && (
<>
<span>{t(item.title)}</span>
{item.showProfessional && !isUnlocked() && (
<Badge variant="outlinePrimary" className="ml-2">
{item.isBeta && (
<Badge
variant="outline"
className="ml-2 text-muted-foreground"
>
{t("beta")}
</Badge>
)}
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
{t("licenseBadge")}
</Badge>
)}
@@ -154,9 +169,11 @@ export function SidebarNav({
{section.items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref);
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled = disabled || isProfessional;
const isEE =
build === "enterprise" &&
item.showEE &&
!isUnlocked();
const isDisabled = disabled || isEE;
return renderNavItem(
item,
hydratedHref,

View File

@@ -7,7 +7,7 @@ import { useState, ReactNode } from "react";
export interface StrategyOption<TValue extends string> {
id: TValue;
title: string;
description: string;
description: string | ReactNode;
disabled?: boolean;
icon?: ReactNode;
}
@@ -68,7 +68,7 @@ export function StrategySelect<TValue extends string>({
<div className="flex-1">
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground">
{option.description}
{typeof option.description === 'string' ? option.description : option.description}
</div>
</div>
</div>

View File

@@ -72,17 +72,14 @@ export interface AuthPageSettingsRef {
hasUnsavedChanges: () => boolean;
}
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(({
onSaveSuccess,
onSaveError
}, ref) => {
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
({ onSaveSuccess, onSaveError }, ref) => {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
// Auth page domain state
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
@@ -111,24 +108,24 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
});
// Expose save function to parent component
useImperativeHandle(ref, () => ({
useImperativeHandle(
ref,
() => ({
saveAuthSettings: async () => {
await form.handleSubmit(onSubmit)();
},
hasUnsavedChanges: () => hasUnsavedChanges
}), [form, hasUnsavedChanges]);
}),
[form, hasUnsavedChanges]
);
// Fetch login page and domains data
useEffect(() => {
if (build !== "saas") {
return;
}
const fetchLoginPage = async () => {
try {
const res = await api.get<AxiosResponse<GetLoginPageResponse>>(
`/org/${org?.org.orgId}/login-page`
);
const res = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
if (res.status === 200) {
setLoginPage(res.data.data);
setLoginPageExists(true);
@@ -153,9 +150,9 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
const fetchDomains = async () => {
try {
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
`/org/${org?.org.orgId}/domains/`
);
const res = await api.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${org?.org.orgId}/domains/`);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
@@ -222,7 +219,10 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (build !== "saas" || (build === "saas" && subscribed)) {
if (
build === "enterprise" ||
(build === "saas" && subscription?.subscribed)
) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
@@ -303,7 +303,10 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(e, t("authPageErrorUpdateMessage"))
description: formatAxiosError(
e,
t("authPageErrorUpdateMessage")
)
});
onSaveError?.(e);
} finally {
@@ -323,7 +326,7 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{build === "saas" && !subscribed ? (
{build === "saas" && !subscription?.subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("orgAuthPageDisabled")}{" "}
@@ -357,7 +360,9 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
info={t(
"domainNotFoundDescription"
)}
text={t("domainNotFound")}
text={t(
"domainNotFound"
)}
/>
) : loginPage?.fullDomain ? (
<a
@@ -400,7 +405,9 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
: domain.baseDomain;
return fullDomain;
}
return t("noDomainSet");
return t(
"noDomainSet"
);
})()
) : (
t("noDomainSet")
@@ -412,14 +419,20 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(true)
setEditDomainOpen(
true
)
}
>
{form.watch("authPageDomainId")
{form.watch(
"authPageDomainId"
)
? t("changeDomain")
: t("selectDomain")}
</Button>
{form.watch("authPageDomainId") && (
{form.watch(
"authPageDomainId"
) && (
<Button
variant="destructive"
type="button"
@@ -435,14 +448,19 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
</div>
{/* Certificate Status */}
{(build !== "saas" ||
(build === "saas" && subscribed)) &&
{(build === "enterprise" ||
(build === "saas" &&
subscription?.subscribed)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={org?.org.orgId || ""}
domainId={loginPage.domainId}
orgId={
org?.org.orgId || ""
}
domainId={
loginPage.domainId
}
fullDomain={
loginPage.fullDomain
}
@@ -452,7 +470,9 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
/>
)}
{!form.watch("authPageDomainId") && (
{!form.watch(
"authPageDomainId"
) && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
@@ -518,8 +538,9 @@ const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
</Credenza>
</>
);
});
}
);
AuthPageSettings.displayName = 'AuthPageSettings';
AuthPageSettings.displayName = "AuthPageSettings";
export default AuthPageSettings;

View File

@@ -6,6 +6,8 @@ type SubscriptionStatusContextType = {
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
isActive: () => boolean;
getTier: () => string | null;
isSubscribed: () => boolean;
subscribed: boolean;
};
const SubscriptionStatusContext = createContext<

View File

@@ -40,13 +40,6 @@ export function LicenseStatusProvider({
) {
return true;
}
if (
licenseStatusState?.maxSites &&
licenseStatusState?.usedSites &&
licenseStatusState.usedSites > licenseStatusState.maxSites
) {
return true;
}
return false;
};

View File

@@ -1,9 +1,10 @@
"use client";
import SubscriptionStatusContext from "@app/contexts/subscriptionStatusContext";
import { getTierPriceSet } from "@server/lib/billing/tiers";
import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
import { GetOrgSubscriptionResponse } from "#private/routers/billing";
import { useState } from "react";
import { build } from "@server/build";
interface ProviderProps {
children: React.ReactNode;
@@ -12,7 +13,7 @@ interface ProviderProps {
sandbox_mode: boolean;
}
export function PrivateSubscriptionStatusProvider({
export function SubscriptionStatusProvider({
children,
subscriptionStatus,
env,
@@ -21,7 +22,9 @@ export function PrivateSubscriptionStatusProvider({
const [subscriptionStatusState, setSubscriptionStatusState] =
useState<GetOrgSubscriptionResponse | null>(subscriptionStatus);
const updateSubscriptionStatus = (updatedSubscriptionStatus: GetOrgSubscriptionResponse) => {
const updateSubscriptionStatus = (
updatedSubscriptionStatus: GetOrgSubscriptionResponse
) => {
setSubscriptionStatusState((prev) => {
return {
...updatedSubscriptionStatus
@@ -43,7 +46,9 @@ export function PrivateSubscriptionStatusProvider({
// Iterate through tiers in order (earlier keys are higher tiers)
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
// Check if any subscription item matches this tier's price ID
const matchingItem = subscriptionStatus.items.find(item => item.priceId === priceId);
const matchingItem = subscriptionStatus.items.find(
(item) => item.priceId === priceId
);
if (matchingItem) {
return tierId;
}
@@ -54,13 +59,24 @@ export function PrivateSubscriptionStatusProvider({
return null;
};
const isSubscribed = () => {
if (build === "enterprise") {
return true;
}
return getTier() === TierId.STANDARD;
};
const [subscribed, setSubscribed] = useState<boolean>(isSubscribed());
return (
<SubscriptionStatusContext.Provider
value={{
subscriptionStatus: subscriptionStatusState,
updateSubscriptionStatus,
isActive,
getTier
getTier,
isSubscribed,
subscribed
}}
>
{children}
@@ -68,4 +84,4 @@ export function PrivateSubscriptionStatusProvider({
);
}
export default PrivateSubscriptionStatusProvider;
export default SubscriptionStatusProvider;

View File

@@ -22,7 +22,7 @@
"#private/*": ["../server/private/*"],
"#open/*": ["../server/*"],
"#closed/*": ["../server/private/*"],
"#dynamic/*": ["../server/*"]
"#dynamic/*": ["../server/private/*"]
},
"plugins": [
{