mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
add enterprise license system
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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-цифрен код",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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자리 코드를 입력하세요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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-значный код",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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位数字代码",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
459
server/private/license/license.ts
Normal file
459
server/private/license/license.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
@@ -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(
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
2
server/private/routers/generatedLicense/index.ts
Normal file
2
server/private/routers/generatedLicense/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./listGeneratedLicenses";
|
||||
export * from "./generateNewLicense";
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
@@ -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({
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
42
src/app/[orgId]/settings/(private)/license/layout.tsx
Normal file
42
src/app/[orgId]/settings/(private)/license/layout.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/app/[orgId]/settings/(private)/license/page.tsx
Normal file
25
src/app/[orgId]/settings/(private)/license/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
src/app/admin/license/layout.tsx
Normal file
17
src/app/admin/license/layout.tsx
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
1384
src/components/GenerateLicenseKeyForm.tsx
Normal file
1384
src/components/GenerateLicenseKeyForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
192
src/components/GenerateLicenseKeysTable.tsx
Normal file
192
src/components/GenerateLicenseKeysTable.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
54
src/components/SidebarLicenseButton.tsx
Normal file
54
src/components/SidebarLicenseButton.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -6,6 +6,8 @@ type SubscriptionStatusContextType = {
|
||||
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
|
||||
isActive: () => boolean;
|
||||
getTier: () => string | null;
|
||||
isSubscribed: () => boolean;
|
||||
subscribed: boolean;
|
||||
};
|
||||
|
||||
const SubscriptionStatusContext = createContext<
|
||||
|
||||
@@ -40,13 +40,6 @@ export function LicenseStatusProvider({
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
licenseStatusState?.maxSites &&
|
||||
licenseStatusState?.usedSites &&
|
||||
licenseStatusState.usedSites > licenseStatusState.maxSites
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"#private/*": ["../server/private/*"],
|
||||
"#open/*": ["../server/*"],
|
||||
"#closed/*": ["../server/private/*"],
|
||||
"#dynamic/*": ["../server/*"]
|
||||
"#dynamic/*": ["../server/private/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user