From 37ceabdf5dde2b0d8cdd3a276a4a47bf9de3108a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 13 Oct 2025 10:41:10 -0700 Subject: [PATCH] add enterprise license system --- drizzle.pg.config.ts | 3 +- drizzle.sqlite.config.ts | 3 +- messages/bg-BG.json | 2 - messages/cs-CZ.json | 2 - messages/de-DE.json | 2 - messages/en-US.json | 112 +- messages/es-ES.json | 2 - messages/fr-FR.json | 2 - messages/it-IT.json | 2 - messages/ko-KR.json | 2 - messages/nb-NO.json | 2 - messages/nl-NL.json | 2 - messages/pl-PL.json | 2 - messages/pt-PT.json | 2 - messages/ru-RU.json | 2 - messages/tr-TR.json | 2 - messages/zh-CN.json | 2 - server/db/pg/schema.ts | 2 +- server/db/sqlite/schema.ts | 2 +- server/lib/config.ts | 13 +- server/lib/readConfigFile.ts | 278 ++-- server/lib/telemetry.ts | 42 +- server/license/license.ts | 467 +----- server/middlewares/index.ts | 3 +- server/private/lib/config.ts | 10 +- server/private/lib/readConfigFile.ts | 6 +- server/private/license/license.ts | 459 ++++++ server/{ => private}/license/licenseJwt.ts | 13 + .../middlewares/verifyValidLicense.ts | 7 +- server/private/routers/external.ts | 163 +- .../generatedLicense/generateNewLicense.ts | 91 ++ .../private/routers/generatedLicense/index.ts | 2 + .../generatedLicense/listGeneratedLicenses.ts | 83 + server/private/routers/internal.ts | 3 + .../routers/license/activateLicense.ts | 3 +- .../routers/license/deleteLicenseKey.ts | 5 +- .../routers/license/getLicenseStatus.ts | 3 +- server/{ => private}/routers/license/index.ts | 0 .../routers/license/listLicenseKeys.ts | 3 +- .../routers/license/recheckStatus.ts | 3 +- server/routers/external.ts | 25 - server/routers/idp/createOidcIdp.ts | 1 - server/routers/idp/updateOidcIdp.ts | 1 - server/routers/internal.ts | 5 +- .../supporterKey/isSupporterKeyVisible.ts | 10 +- src/app/[orgId]/layout.tsx | 8 +- .../settings/(private)/billing/layout.tsx | 7 - .../[orgId]/settings/(private)/idp/page.tsx | 5 +- .../settings/(private)/license/layout.tsx | 42 + .../settings/(private)/license/page.tsx | 25 + .../settings/access/users/create/page.tsx | 479 +++--- src/app/[orgId]/settings/clients/page.tsx | 7 +- src/app/[orgId]/settings/general/page.tsx | 14 +- .../[niceId]/authentication/page.tsx | 9 +- src/app/admin/license/layout.tsx | 17 + src/app/admin/license/page.tsx | 297 ++-- src/app/admin/managed/page.tsx | 180 --- src/app/auth/(private)/org/page.tsx | 25 +- src/app/auth/layout.tsx | 66 - src/app/auth/resource/[resourceGuid]/page.tsx | 12 +- src/app/layout.tsx | 20 +- src/app/navigation.tsx | 27 +- src/components/GenerateLicenseKeyForm.tsx | 1384 +++++++++++++++++ src/components/GenerateLicenseKeysTable.tsx | 192 +++ src/components/LayoutSidebar.tsx | 87 +- src/components/LicenseKeysDataTable.tsx | 42 +- src/components/LicenseViolation.tsx | 24 - src/components/SidebarLicenseButton.tsx | 54 + src/components/SidebarNav.tsx | 33 +- src/components/SitesTable.tsx | 2 +- src/components/StrategySelect.tsx | 4 +- src/components/private/AuthPageSettings.tsx | 867 ++++++----- src/contexts/subscriptionStatusContext.ts | 2 + src/providers/LicenseStatusProvider.tsx | 7 - src/providers/SubscriptionStatusProvider.tsx | 28 +- tsconfig.json | 2 +- 76 files changed, 3886 insertions(+), 1931 deletions(-) create mode 100644 server/private/license/license.ts rename server/{ => private}/license/licenseJwt.ts (88%) rename server/{ => private}/middlewares/verifyValidLicense.ts (81%) create mode 100644 server/private/routers/generatedLicense/generateNewLicense.ts create mode 100644 server/private/routers/generatedLicense/index.ts create mode 100644 server/private/routers/generatedLicense/listGeneratedLicenses.ts rename server/{ => private}/routers/license/activateLicense.ts (93%) rename server/{ => private}/routers/license/deleteLicenseKey.ts (92%) rename server/{ => private}/routers/license/getLicenseStatus.ts (89%) rename server/{ => private}/routers/license/index.ts (100%) rename server/{ => private}/routers/license/listLicenseKeys.ts (89%) rename server/{ => private}/routers/license/recheckStatus.ts (91%) create mode 100644 src/app/[orgId]/settings/(private)/license/layout.tsx create mode 100644 src/app/[orgId]/settings/(private)/license/page.tsx create mode 100644 src/app/admin/license/layout.tsx delete mode 100644 src/app/admin/managed/page.tsx create mode 100644 src/components/GenerateLicenseKeyForm.tsx create mode 100644 src/components/GenerateLicenseKeysTable.tsx create mode 100644 src/components/SidebarLicenseButton.tsx diff --git a/drizzle.pg.config.ts b/drizzle.pg.config.ts index 8fc99161..f6dbb665 100644 --- a/drizzle.pg.config.ts +++ b/drizzle.pg.config.ts @@ -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({ diff --git a/drizzle.sqlite.config.ts b/drizzle.sqlite.config.ts index b8679aa9..df635931 100644 --- a/drizzle.sqlite.config.ts +++ b/drizzle.sqlite.config.ts @@ -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({ diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 9cc9e9f0..74bf5d87 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -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-цифрен код", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index f1106fb8..9d573022 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -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", diff --git a/messages/de-DE.json b/messages/de-DE.json index 5ac9fc02..eaca92bf 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -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", diff --git a/messages/en-US.json b/messages/en-US.json index 9435b5d7..b49f40a9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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." + } + } + } } diff --git a/messages/es-ES.json b/messages/es-ES.json index a1b92f8b..2e7cf00a 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -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", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 6028ab5b..4a1670f3 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -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", diff --git a/messages/it-IT.json b/messages/it-IT.json index ca22ba63..143da0c5 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -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", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 3d010cd5..8ace81a5 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -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자리 코드를 입력하세요", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 84dc5266..f7f0d3ab 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -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", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index fb82fbb6..34d1c811 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -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", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index c3db35f5..08287ed9 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -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", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 1d61c581..5a45eedd 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -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", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 80be36dd..c41215d2 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -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-значный код", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index b84bef19..6296b7fe 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -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", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 4f1d9c14..a8f578db 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -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位数字代码", diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index e94bfe36..f7d45766 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -720,4 +720,4 @@ export type OrgDomains = InferSelectModel; export type SiteResource = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; -export type TargetHealthCheck = InferSelectModel; \ No newline at end of file +export type TargetHealthCheck = InferSelectModel; diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 9d64b85e..e3b0f7ed 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -759,4 +759,4 @@ export type SiteResource = InferSelectModel; export type OrgDomains = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; -export type TargetHealthCheck = InferSelectModel; \ No newline at end of file +export type TargetHealthCheck = InferSelectModel; diff --git a/server/lib/config.ts b/server/lib/config.ts index 103ea5ae..03b9e1ee 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -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; @@ -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(); } } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index ea872252..af978c5d 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -12,39 +12,45 @@ const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { export const configSchema = z .object({ - app: z.object({ - dashboard_url: z - .string() - .url() - .pipe(z.string().url()) - .transform((url) => url.toLowerCase()) - .optional(), - log_level: z - .enum(["debug", "info", "warn", "error"]) - .optional() - .default("info"), - save_logs: z.boolean().optional().default(false), - log_failed_attempts: z.boolean().optional().default(false), - telemetry: z - .object({ - anonymous_usage: z.boolean().optional().default(true) - }) - .optional() - .default({}), - }).optional().default({ - log_level: "info", - save_logs: false, - log_failed_attempts: false, - telemetry: { - anonymous_usage: true - } - }), + app: z + .object({ + dashboard_url: z + .string() + .url() + .pipe(z.string().url()) + .transform((url) => url.toLowerCase()) + .optional(), + log_level: z + .enum(["debug", "info", "warn", "error"]) + .optional() + .default("info"), + save_logs: z.boolean().optional().default(false), + log_failed_attempts: z.boolean().optional().default(false), + telemetry: z + .object({ + anonymous_usage: z.boolean().optional().default(true) + }) + .optional() + .default({}) + }) + .optional() + .default({ + log_level: "info", + save_logs: false, + log_failed_attempts: false, + telemetry: { + anonymous_usage: true + } + }), managed: z .object({ 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,94 +67,95 @@ export const configSchema = z }) ) .optional(), - server: z.object({ - integration_port: portSchema - .optional() - .default(3003) - .transform(stoi) - .pipe(portSchema.optional()), - external_port: portSchema - .optional() - .default(3000) - .transform(stoi) - .pipe(portSchema), - internal_port: portSchema - .optional() - .default(3001) - .transform(stoi) - .pipe(portSchema), - next_port: portSchema - .optional() - .default(3002) - .transform(stoi) - .pipe(portSchema), - internal_hostname: z - .string() - .optional() - .default("pangolin") - .transform((url) => url.toLowerCase()), - session_cookie_name: z - .string() - .optional() - .default("p_session_token"), - resource_access_token_param: z - .string() - .optional() - .default("p_token"), - resource_access_token_headers: z - .object({ - id: z.string().optional().default("P-Access-Token-Id"), - token: z.string().optional().default("P-Access-Token") - }) - .optional() - .default({}), - resource_session_request_param: z - .string() - .optional() - .default("resource_session_request_param"), - dashboard_session_length_hours: z - .number() - .positive() - .gt(0) - .optional() - .default(720), - resource_session_length_hours: z - .number() - .positive() - .gt(0) - .optional() - .default(720), - cors: z - .object({ - origins: z.array(z.string()).optional(), - methods: z.array(z.string()).optional(), - allowed_headers: z.array(z.string()).optional(), - credentials: z.boolean().optional() - }) - .optional(), - trust_proxy: z.number().int().gte(0).optional().default(1), - secret: z - .string() - .pipe(z.string().min(8)) - .optional(), - maxmind_db_path: z.string().optional() - }).optional().default({ - integration_port: 3003, - external_port: 3000, - internal_port: 3001, - next_port: 3002, - internal_hostname: "pangolin", - session_cookie_name: "p_session_token", - resource_access_token_param: "p_token", - resource_access_token_headers: { - id: "P-Access-Token-Id", - token: "P-Access-Token" - }, - resource_session_request_param: "resource_session_request_param", - dashboard_session_length_hours: 720, - resource_session_length_hours: 720, - trust_proxy: 1 - }), + server: z + .object({ + integration_port: portSchema + .optional() + .default(3003) + .transform(stoi) + .pipe(portSchema.optional()), + external_port: portSchema + .optional() + .default(3000) + .transform(stoi) + .pipe(portSchema), + internal_port: portSchema + .optional() + .default(3001) + .transform(stoi) + .pipe(portSchema), + next_port: portSchema + .optional() + .default(3002) + .transform(stoi) + .pipe(portSchema), + internal_hostname: z + .string() + .optional() + .default("pangolin") + .transform((url) => url.toLowerCase()), + session_cookie_name: z + .string() + .optional() + .default("p_session_token"), + resource_access_token_param: z + .string() + .optional() + .default("p_token"), + resource_access_token_headers: z + .object({ + id: z.string().optional().default("P-Access-Token-Id"), + token: z.string().optional().default("P-Access-Token") + }) + .optional() + .default({}), + resource_session_request_param: z + .string() + .optional() + .default("resource_session_request_param"), + dashboard_session_length_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(720), + resource_session_length_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(720), + cors: z + .object({ + origins: z.array(z.string()).optional(), + methods: z.array(z.string()).optional(), + allowed_headers: z.array(z.string()).optional(), + credentials: z.boolean().optional() + }) + .optional(), + trust_proxy: z.number().int().gte(0).optional().default(1), + secret: z.string().pipe(z.string().min(8)).optional(), + maxmind_db_path: z.string().optional() + }) + .optional() + .default({ + integration_port: 3003, + external_port: 3000, + internal_port: 3001, + next_port: 3002, + internal_hostname: "pangolin", + session_cookie_name: "p_session_token", + resource_access_token_param: "p_token", + resource_access_token_headers: { + id: "P-Access-Token-Id", + token: "P-Access-Token" + }, + resource_session_request_param: + "resource_session_request_param", + dashboard_session_length_hours: 720, + resource_session_length_hours: 720, + trust_proxy: 1 + }), postgres: z .object({ connection_string: z.string().optional(), @@ -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" diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index 827f1c7e..0e0ae24e 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -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,17 +177,36 @@ class TelemetryClient { const stats = await this.getSystemStats(); - this.client.capture({ - distinctId: hostMeta.hostMetaId, - event: "supporter_status", - properties: { - valid: stats.supporterStatus.valid, - tier: stats.supporterStatus.tier, - github_username: stats.supporterStatus.githubUsername - ? this.anon(stats.supporterStatus.githubUsername) - : "None" - } - }); + 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", + properties: { + valid: stats.supporterStatus.valid, + tier: stats.supporterStatus.tier, + github_username: stats.supporterStatus.githubUsername + ? this.anon(stats.supporterStatus.githubUsername) + : "None" + } + }); + } this.client.capture({ distinctId: hostMeta.hostMetaId, diff --git a/server/license/license.ts b/server/license/license.ts index aeb628df..919fdb03 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -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(key)!; - }); + public async check(): Promise { + 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 { - const status = await this.check(); - if (status.isHostLicensed) { - if (status.isLicenseValid) { - return true; - } - } - return false; - } - - public async check(): Promise { - // Set used sites - const [siteCount] = await db - .select({ - value: count() - }) - .from(sites); - - const status: LicenseStatus = { - hostId: this.hostId, - isHostLicensed: true, - isLicenseValid: false, - maxSites: undefined, - usedSites: 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( - decryptedToken, - this.publicKey - ); - - this.licenseKeyCache.set(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( - 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( - key.licenseKey - )!; - const licenseKeyRes = - apiResponse?.data?.licenseKeys[key.licenseKey]; - - if (!apiResponse || !licenseKeyRes) { - logger.debug( - `No response from server for license key: ${key.licenseKey}` - ); - if (cached.iat) { - const exp = moment(cached.iat) - .add(7, "days") - .toDate(); - if (exp > new Date()) { - logger.debug( - `Using cached license key: ${key.licenseKey}, valid ${cached.valid}` - ); - continue; - } - } - - logger.debug( - `Can't trust license key: ${key.licenseKey}` - ); - cached.valid = false; - this.licenseKeyCache.set( - key.licenseKey, - cached - ); - continue; - } - - const payload = validateJWT( - licenseKeyRes, - this.publicKey - ); - cached.valid = payload.valid; - cached.type = payload.type; - cached.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( - key.licenseKey, - cached - ); - } catch (e) { - logger.error(`Error validating license key: ${key}`); - logger.error(e); - } - } - - // Compute host status - for (const key of keys) { - const cached = this.licenseKeyCache.get( - key.licenseKey - )!; - - logger.debug("Checking key", cached); - - if (cached.type === "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( - 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 { - // 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; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index f211fa9e..629cafe9 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -21,10 +21,9 @@ export * from "./verifyIsLoggedInUser"; export * from "./verifyIsLoggedInUser"; export * from "./verifyClientAccess"; export * from "./integration"; -export * from "./verifyValidLicense"; export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; export * from "./verifyDomainAccess"; export * from "./verifyClientsEnabled"; export * from "./verifyUserIsOrgOwner"; -export * from "./verifySiteResourceAccess"; \ No newline at end of file +export * from "./verifySiteResourceAccess"; diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index a0dbf9a3..c6ecdde7 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -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; diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index c1847ba5..c2af299c 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -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); } diff --git a/server/private/license/license.ts b/server/private/license/license.ts new file mode 100644 index 00000000..d1138539 --- /dev/null +++ b/server/private/license/license.ts @@ -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(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 { + const status = await this.check(); + if (status.isHostLicensed) { + if (status.isLicenseValid) { + return true; + } + } + return false; + } + + public async check(): Promise { + 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( + decryptedToken, + this.publicKey + ); + + this.licenseKeyCache.set(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( + 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( + key.licenseKey + )!; + const licenseKeyRes = + apiResponse?.data?.licenseKeys[key.licenseKey]; + + if (!apiResponse || !licenseKeyRes) { + logger.debug( + `No response from server for license key: ${key.licenseKey}` + ); + if (cached.iat) { + const exp = moment(cached.iat) + .add(7, "days") + .toDate(); + if (exp > new Date()) { + logger.debug( + `Using cached license key: ${key.licenseKey}, valid ${cached.valid}` + ); + continue; + } + } + + logger.debug( + `Can't trust license key: ${key.licenseKey}` + ); + cached.valid = false; + this.licenseKeyCache.set( + key.licenseKey, + cached + ); + continue; + } + + const payload = validateJWT( + licenseKeyRes, + this.publicKey + ); + cached.valid = payload.valid; + cached.type = payload.type; + cached.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( + key.licenseKey, + cached + ); + } catch (e) { + logger.error(`Error validating license key: ${key}`); + logger.error(e); + } + } + + // Compute host status + for (const key of keys) { + const cached = this.licenseKeyCache.get( + key.licenseKey + )!; + + 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( + 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 { + // 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; diff --git a/server/license/licenseJwt.ts b/server/private/license/licenseJwt.ts similarity index 88% rename from server/license/licenseJwt.ts rename to server/private/license/licenseJwt.ts index 3d148e51..f137db30 100644 --- a/server/license/licenseJwt.ts +++ b/server/private/license/licenseJwt.ts @@ -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"; /** diff --git a/server/middlewares/verifyValidLicense.ts b/server/private/middlewares/verifyValidLicense.ts similarity index 81% rename from server/middlewares/verifyValidLicense.ts rename to server/private/middlewares/verifyValidLicense.ts index 7e3bfee3..b265fcbd 100644 --- a/server/middlewares/verifyValidLicense.ts +++ b/server/private/middlewares/verifyValidLicense.ts @@ -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( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index fac7c0c4..c91943f6 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -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( - "/org/:orgId/billing/create-checkout-session", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.billing), - billing.createCheckoutSession -); +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-portal-session", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.billing), - billing.createPortalSession -); + authenticated.post( + "/org/:orgId/billing/create-checkout-session", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + billing.createCheckoutSession + ); + + authenticated.post( + "/org/:orgId/billing/create-portal-session", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + billing.createPortalSession + ); + + authenticated.get( + "/org/:orgId/billing/subscription", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + billing.getOrgSubscription + ); + + authenticated.get( + "/org/:orgId/billing/usage", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + billing.getOrgUsage + ); + + authenticated.get( + "/org/:orgId/license", + verifyOrgAccess, + generateLicense.listSaasLicenseKeys + ); + + authenticated.put( + "/org/:orgId/license", + verifyOrgAccess, + generateLicense.generateNewLicense + ); +} authenticated.get( - "/org/:orgId/billing/subscription", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.billing), - billing.getOrgSubscription + "/domain/namespaces", + verifyValidLicense, + domain.listDomainNamespaces ); -authenticated.get( - "/org/:orgId/billing/usage", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.billing), - billing.getOrgUsage -); - -authenticated.get("/domain/namespaces", 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, @@ -259,4 +310,28 @@ authRouter.post( store: createStore() }), auth.transferSession -); \ No newline at end of file +); + +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 +); diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts new file mode 100644 index 00000000..cb179a2d --- /dev/null +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -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 { + 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 { + 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(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" + ) + ); + } +} diff --git a/server/private/routers/generatedLicense/index.ts b/server/private/routers/generatedLicense/index.ts new file mode 100644 index 00000000..fa07430f --- /dev/null +++ b/server/private/routers/generatedLicense/index.ts @@ -0,0 +1,2 @@ +export * from "./listGeneratedLicenses"; +export * from "./generateNewLicense"; diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts new file mode 100644 index 00000000..ee5f96be --- /dev/null +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -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 { + 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 { + 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(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" + ) + ); + } +} diff --git a/server/private/routers/internal.ts b/server/private/routers/internal.ts index ab3db1ce..444d416e 100644 --- a/server/private/routers/internal.ts +++ b/server/private/routers/internal.ts @@ -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); diff --git a/server/routers/license/activateLicense.ts b/server/private/routers/license/activateLicense.ts similarity index 93% rename from server/routers/license/activateLicense.ts rename to server/private/routers/license/activateLicense.ts index 832bc19d..f6d73f6f 100644 --- a/server/routers/license/activateLicense.ts +++ b/server/private/routers/license/activateLicense.ts @@ -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({ diff --git a/server/routers/license/deleteLicenseKey.ts b/server/private/routers/license/deleteLicenseKey.ts similarity index 92% rename from server/routers/license/deleteLicenseKey.ts rename to server/private/routers/license/deleteLicenseKey.ts index 37b74fee..bcee5b6a 100644 --- a/server/routers/license/deleteLicenseKey.ts +++ b/server/private/routers/license/deleteLicenseKey.ts @@ -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({ diff --git a/server/routers/license/getLicenseStatus.ts b/server/private/routers/license/getLicenseStatus.ts similarity index 89% rename from server/routers/license/getLicenseStatus.ts rename to server/private/routers/license/getLicenseStatus.ts index e4f28882..b36d8dca 100644 --- a/server/routers/license/getLicenseStatus.ts +++ b/server/private/routers/license/getLicenseStatus.ts @@ -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; diff --git a/server/routers/license/index.ts b/server/private/routers/license/index.ts similarity index 100% rename from server/routers/license/index.ts rename to server/private/routers/license/index.ts diff --git a/server/routers/license/listLicenseKeys.ts b/server/private/routers/license/listLicenseKeys.ts similarity index 89% rename from server/routers/license/listLicenseKeys.ts rename to server/private/routers/license/listLicenseKeys.ts index d106abd7..338c92c4 100644 --- a/server/routers/license/listLicenseKeys.ts +++ b/server/private/routers/license/listLicenseKeys.ts @@ -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[]; diff --git a/server/routers/license/recheckStatus.ts b/server/private/routers/license/recheckStatus.ts similarity index 91% rename from server/routers/license/recheckStatus.ts rename to server/private/routers/license/recheckStatus.ts index cd4bf779..73e630c8 100644 --- a/server/routers/license/recheckStatus.ts +++ b/server/private/routers/license/recheckStatus.ts @@ -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; diff --git a/server/routers/external.ts b/server/routers/external.ts index d90b7478..8bd72f62 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -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, diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 223a08b8..67357d76 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -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(); diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index 904d0d9e..53ece68e 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -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({ diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 10966bb5..561408aa 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -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); @@ -96,4 +93,4 @@ if (config.isManagedMode()) { ); } else { badgerRouter.post("/exchange-session", badger.exchangeSession); -} \ No newline at end of file +} diff --git a/server/routers/supporterKey/isSupporterKeyVisible.ts b/server/routers/supporterKey/isSupporterKeyVisible.ts index 317f6461..da995447 100644 --- a/server/routers/supporterKey/isSupporterKeyVisible.ts +++ b/server/routers/supporterKey/isSupporterKeyVisible.ts @@ -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; } diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index d8f28a59..5d9a724a 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -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>( @@ -73,13 +73,13 @@ export default async function OrgLayout(props: { } return ( - {props.children} - + ); } diff --git a/src/app/[orgId]/settings/(private)/billing/layout.tsx b/src/app/[orgId]/settings/(private)/billing/layout.tsx index 3bb60caf..538c7fde 100644 --- a/src/app/[orgId]/settings/(private)/billing/layout.tsx +++ b/src/app/[orgId]/settings/(private)/billing/layout.tsx @@ -60,13 +60,6 @@ export default async function BillingSettingsPage({ const t = await getTranslations(); - const navItems = [ - { - title: t('billing'), - href: `/{orgId}/settings/billing`, - }, - ]; - return ( <> diff --git a/src/app/[orgId]/settings/(private)/idp/page.tsx b/src/app/[orgId]/settings/(private)/idp/page.tsx index 2d1882b9..551de34f 100644 --- a/src/app/[orgId]/settings/(private)/idp/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/page.tsx @@ -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 ( <> diff --git a/src/app/[orgId]/settings/(private)/license/layout.tsx b/src/app/[orgId]/settings/(private)/license/layout.tsx new file mode 100644 index 00000000..9083bb81 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/license/layout.tsx @@ -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 ( + <> + + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/(private)/license/page.tsx b/src/app/[orgId]/settings/(private)/license/page.tsx new file mode 100644 index 00000000..627618f4 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/license/page.tsx @@ -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 + >(`/org/${orgId}/license`, await authCookieHeader()); + licenseKeys = data.data.data; + } catch {} + + return ; +} diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 2b6fddad..d789b2e2 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -77,9 +77,10 @@ export default function Page() { const t = useTranslations(); const subscription = useSubscriptionStatusContext(); - const subscribed = subscription?.getTier() === TierId.STANDARD; - const [selectedOption, setSelectedOption] = useState("internal"); + const [selectedOption, setSelectedOption] = useState( + "internal" + ); const [inviteLink, setInviteLink] = useState(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 ) { - 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 ) { - const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); + const selectedUserOption = userOptions.find( + (opt) => opt.id === selectedOption + ); if (!selectedUserOption?.idpId) return; setLoading(true); @@ -675,214 +686,284 @@ export default function Page() { )} - {selectedOption && selectedOption !== "internal" && dataLoaded && ( - - - - {t("userSettings")} - - - {t("userSettingsDescription")} - - - - - {/* Google/Azure Form */} - {(() => { - const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); - return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure"; - })() && ( -
- + + + {t("userSettings")} + + + {t("userSettingsDescription")} + + + + + {/* Google/Azure Form */} + {(() => { + const selectedUserOption = + userOptions.find( + (opt) => + opt.id === + selectedOption + ); + return ( + selectedUserOption?.variant === + "google" || + selectedUserOption?.variant === + "azure" + ); + })() && ( + + + ( + + + {t("email")} + + + + + + )} - className="space-y-4" - id="create-user-form" - > - ( - - - {t("email")} - - - - - - - )} - /> + /> - ( - - - {t("nameOptional")} - - - - - - - )} - /> + ( + + + {t( + "nameOptional" + )} + + + + + + + )} + /> - ( - - - {t("role")} - - + + + + + + + {roles.map( + ( + role + ) => ( - {role.name} + { + role.name + } - ))} - - - - - )} - /> - - - )} - - {/* Generic OIDC Form */} - {(() => { - const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); - return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure"; - })() && ( -
- + + + )} - className="space-y-4" - id="create-user-form" - > - ( - - - {t("username")} - - - - -

- {t("usernameUniq")} -

- -
- )} - /> + /> + + + )} - ( - - - {t("emailOptional")} - - - - - - - )} - /> + {/* Generic OIDC Form */} + {(() => { + const selectedUserOption = + userOptions.find( + (opt) => + opt.id === + selectedOption + ); + return ( + selectedUserOption?.variant !== + "google" && + selectedUserOption?.variant !== + "azure" + ); + })() && ( +
+ + ( + + + {t( + "username" + )} + + + + +

+ {t( + "usernameUniq" + )} +

+ +
+ )} + /> - ( - - - {t("nameOptional")} - - - - - - - )} - /> + ( + + + {t( + "emailOptional" + )} + + + + + + + )} + /> - ( - - - {t("role")} - - + + + + )} + /> + + ( + + + {t("role")} + + - - - )} - /> - - - )} -
-
-
- )} + ) + )} + + + + + )} + /> + + + )} + + + + )}
diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 994b1d56..0813ad3c 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -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 ( <> diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index a7948536..1801bcf2 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -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() { - {build === "saas" && } + {(build === "saas") && ( + + )} {/* Save Button */}
@@ -276,7 +283,6 @@ export default function GeneralPage() { )} - ); } diff --git a/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx index 6a088196..fb79c59a 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx @@ -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, diff --git a/src/app/admin/license/layout.tsx b/src/app/admin/license/layout.tsx new file mode 100644 index 00000000..6c6e8baf --- /dev/null +++ b/src/app/admin/license/layout.tsx @@ -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; +} + diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index a871b8e0..665212fc 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -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(null); - const router = useRouter(); + const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext(); const [hostLicense, setHostLicense] = useState(null); const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false); - const [purchaseMode, setPurchaseMode] = useState< - "license" | "additional-sites" - >("license"); + 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() { > - {t('licenseActivateKey')} + {t("licenseActivateKey")} - {t('licenseActivateKeyDescription')} + {t("licenseActivateKeyDescription")} @@ -263,7 +256,9 @@ export default function LicensePage() { name="licenseKey" render={({ field }) => ( - {t('licenseKey')} + + {t("licenseKey")} + @@ -286,16 +281,7 @@ export default function LicensePage() {
- {t('licenseAgreement')} - {/*
*/} - {/* */} - {/* {t('fossorialLicense')} */} - {/* */} + {t("licenseAgreement")}
@@ -307,7 +293,7 @@ export default function LicensePage() {
- +
@@ -331,187 +317,98 @@ export default function LicensePage() { dialog={

- {t('licenseQuestionRemove', {selectedKey: obfuscateLicenseKey(selectedLicenseKey.licenseKey)})} + {t("licenseQuestionRemove", { + selectedKey: obfuscateLicenseKey( + selectedLicenseKey.licenseKey + ) + })}

- - {t('licenseMessageRemove')} - -

-

- {t('licenseMessageConfirm')} + {t("licenseMessageRemove")}

+

{t("licenseMessageConfirm")}

} - buttonText={t('licenseKeyDeleteConfirm')} + buttonText={t("licenseKeyDeleteConfirm")} onConfirm={async () => deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted) } string={selectedLicenseKey.licenseKey} - title={t('licenseKeyDelete')} + title={t("licenseKeyDelete")} /> )} - - - - {t('licenseAbout')} - - - {t('licenseAboutDescription')} - - + {/* */} + {/* */} + {/* */} + {/* {t("licenseAbout")} */} + {/* */} + {/* */} + {/* {t("licenseAboutDescription")} */} + {/* */} + {/* */} - - - - {t('licenseHost')} - - {t('licenseHostDescription')} - - -
-
- {licenseStatus?.isLicenseValid ? ( -
-
- - {licenseStatus?.tier === - "PROFESSIONAL" - ? t('licenseTierCommercial') - : licenseStatus?.tier === - "ENTERPRISE" - ? t('licenseTierCommercial') - : t('licensed')} -
+ + + {t("licenseHost")} + + {t("licenseHostDescription")} + + +
+
+ {licenseStatus?.isLicenseValid ? ( +
+
+ + {t("licensed")}
- ) : ( -
- {supporterStatus?.visible ? ( -
- {t('communityEdition')} -
- ) : ( -
- - {t('communityEdition')} -
- )} -
- )} -
- {licenseStatus?.hostId && ( -
-
- {t('hostId')} -
-
- )} - {hostLicense && ( -
-
- {t('licenseKey')} -
- -
- )} -
- - - - - - - {t('licenseSiteUsage')} - - {t('licenseSiteUsageDecsription')} - - -
-
+ ) : (
- {t('licenseSitesUsed', {count: licenseStatus?.usedSites || 0})} -
-
- {!licenseStatus?.isHostLicensed && ( -

- {t('licenseNoSiteLimit')} -

- )} - {licenseStatus?.maxSites && ( -
-
- - {t('licenseSitesUsedMax', {usedSites: licenseStatus.usedSites || 0, maxSites: licenseStatus.maxSites})} - - - {Math.round( - ((licenseStatus.usedSites || - 0) / - licenseStatus.maxSites) * - 100 - )} - % - -
- + {t("unlicensed")}
)}
- {/* */} - {/* {!licenseStatus?.isHostLicensed ? ( */} - {/* <> */} - {/* */} - {/* */} - {/* ) : ( */} - {/* <> */} - {/* */} - {/* */} - {/* )} */} - {/* */} -
- + {licenseStatus?.hostId && ( +
+
+ {t("hostId")} +
+ +
+ )} + {hostLicense && ( +
+
+ {t("licenseKey")} +
+ +
+ )} +
+ + + +
{ diff --git a/src/app/admin/managed/page.tsx b/src/app/admin/managed/page.tsx deleted file mode 100644 index f7a8f70b..00000000 --- a/src/app/admin/managed/page.tsx +++ /dev/null @@ -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 ( - <> - - - - - -

- {t("managedSelfHosted.introTitle")}{" "} - {t("managedSelfHosted.introDescription")} -

-

- {t("managedSelfHosted.introDetail")} -

- -
-
-
- -
-

- {t( - "managedSelfHosted.benefitSimplerOperations.title" - )} -

-

- {t( - "managedSelfHosted.benefitSimplerOperations.description" - )} -

-
-
- -
- -
-

- {t( - "managedSelfHosted.benefitAutomaticUpdates.title" - )} -

-

- {t( - "managedSelfHosted.benefitAutomaticUpdates.description" - )} -

-
-
- -
- -
-

- {t( - "managedSelfHosted.benefitLessMaintenance.title" - )} -

-

- {t( - "managedSelfHosted.benefitLessMaintenance.description" - )} -

-
-
-
- -
-
- -
-

- {t( - "managedSelfHosted.benefitCloudFailover.title" - )} -

-

- {t( - "managedSelfHosted.benefitCloudFailover.description" - )} -

-
-
-
- -
-

- {t( - "managedSelfHosted.benefitHighAvailability.title" - )} -

-

- {t( - "managedSelfHosted.benefitHighAvailability.description" - )} -

-
-
- -
- -
-

- {t( - "managedSelfHosted.benefitFutureEnhancements.title" - )} -

-

- {t( - "managedSelfHosted.benefitFutureEnhancements.description" - )} -

-
-
-
-
- - - {t("managedSelfHosted.docsAlert.text")}{" "} - - {t("managedSelfHosted.docsAlert.documentation")} - - - . - -
- - - - - -
-
- - ); -} diff --git a/src/app/auth/(private)/org/page.tsx b/src/app/auth/(private)/org/page.tsx index c438ba66..491aeb67 100644 --- a/src/app/auth/(private)/org/page.tsx +++ b/src/app/auth/(private)/org/page.tsx @@ -74,16 +74,21 @@ export default async function OrgAuthPage(props: { } let subscriptionStatus: GetOrgTierResponse | null = null; - try { - const getSubscription = cache(() => - priv.get>( - `/org/${loginPage!.orgId}/billing/tier` - ) - ); - const subRes = await getSubscription(); - subscriptionStatus = subRes.data.data; - } catch {} - const subscribed = subscriptionStatus?.tier === TierId.STANDARD; + if (build === "saas") { + try { + const getSubscription = cache(() => + priv.get>( + `/org/${loginPage!.orgId}/billing/tier` + ) + ); + const subRes = await getSubscription(); + subscriptionStatus = subRes.data.data; + } catch {} + } + const subscribed = + build === "enterprise" + ? true + : subscriptionStatus?.tier === TierId.STANDARD; if (build === "saas" && !subscribed) { redirect(env.app.dashboardUrl); diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 0902baaa..88b0f07d 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -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>( - "/license/status" - ) - )(); - const licenseStatus = licenseStatusRes.data.data; - return (
@@ -43,49 +20,6 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
{children}
- - {!( - hideFooter || ( - licenseStatus.isHostLicensed && - licenseStatus.isLicenseValid) - ) && ( - - )}
); } diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index d37bc8ca..8789cd38 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -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 ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 29d49300..2d6eb965 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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; - const licenseStatusRes = await cache( - async () => + let licenseStatus: GetLicenseStatusResponse; + if (build === "enterprise") { + const licenseStatusRes = await cache( + async () => await priv.get>( "/license/status" ) - )(); - const licenseStatus = licenseStatusRes.data.data; + )(); + licenseStatus = licenseStatusRes.data.data; + } else { + licenseStatus = { + isHostLicensed: false, + isLicenseValid: false, + hostId: "" + }; + } return ( diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 369de1d4..a3311b68 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -54,7 +54,8 @@ export const orgNavSections = ( { title: "sidebarClients", href: "/{orgId}/settings/clients", - icon: + icon: , + isBeta: true } ] : []), @@ -63,7 +64,8 @@ export const orgNavSections = ( { title: "sidebarRemoteExitNodes", href: "/{orgId}/settings/remote-exit-nodes", - icon: + icon: , + showEE: true } ] : []), @@ -97,7 +99,8 @@ export const orgNavSections = ( { title: "sidebarIdentityProviders", href: "/{orgId}/settings/idp", - icon: + icon: , + showEE: true } ] : []), @@ -116,15 +119,6 @@ export const orgNavSections = ( href: "/{orgId}/settings/api-keys", icon: }, - ...(build == "saas" - ? [ - { - title: "sidebarBilling", - href: "/{orgId}/settings/billing", - icon: - } - ] - : []), { 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: - } - ] - : []), { title: "sidebarAllUsers", href: "/admin/users", diff --git a/src/components/GenerateLicenseKeyForm.tsx b/src/components/GenerateLicenseKeyForm.tsx new file mode 100644 index 00000000..dfb45a45 --- /dev/null +++ b/src/components/GenerateLicenseKeyForm.tsx @@ -0,0 +1,1384 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { toast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosResponse } from "axios"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { GenerateNewLicenseResponse } from "@server/private/routers/generatedLicense/generateNewLicense"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { StrategySelect, StrategyOption } from "./StrategySelect"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { InfoIcon, Check } from "lucide-react"; +import { useUserContext } from "@app/hooks/useUserContext"; + +type FormProps = { + open: boolean; + setOpen: (open: boolean) => void; + orgId: string; + onGenerated?: () => void; +}; + +export default function GenerateLicenseKeyForm({ + open, + setOpen, + orgId, + onGenerated +}: FormProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const { user } = useUserContext(); + + const [currentStep, setCurrentStep] = useState(1); + const [loading, setLoading] = useState(false); + const [generatedKey, setGeneratedKey] = useState(null); + const [formKey, setFormKey] = useState(0); + + // Step 1: Email & License Type + const step1Schema = z.object({ + email: z + .string() + .email(t("generateLicenseKeyForm.validation.emailRequired")), + useCaseType: z.enum(["personal", "business"], { + required_error: t( + "generateLicenseKeyForm.validation.useCaseTypeRequired" + ) + }) + }); + + // Step 2: Personal Information + const createStep2Schema = (useCaseType: string | undefined) => + z + .object({ + firstName: z + .string() + .min( + 1, + t("generateLicenseKeyForm.validation.firstNameRequired") + ), + lastName: z + .string() + .min( + 1, + t("generateLicenseKeyForm.validation.lastNameRequired") + ), + jobTitle: z.string().optional(), + primaryUse: z + .string() + .min( + 1, + t( + "generateLicenseKeyForm.validation.primaryUseRequired" + ) + ), + industry: z.string().optional(), + prospectiveUsers: z.coerce.number().optional(), + prospectiveSites: z.coerce.number().optional() + }) + .refine( + (data) => { + // If business use case, job title is required + if (useCaseType === "business") { + return data.jobTitle; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.jobTitleRequiredBusiness" + ), + path: ["jobTitle"] + } + ) + .refine( + (data) => { + // If business use case, industry is required + if (useCaseType === "business") { + return data.industry; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.industryRequiredBusiness" + ), + path: ["industry"] + } + ); + + // Step 3: Contact Information + const createStep3Schema = (useCaseType: string | undefined) => + z + .object({ + stateProvinceRegion: z + .string() + .min( + 1, + t( + "generateLicenseKeyForm.validation.stateProvinceRegionRequired" + ) + ), + postalZipCode: z + .string() + .min( + 1, + t( + "generateLicenseKeyForm.validation.postalZipCodeRequired" + ) + ), + country: z.string().optional(), + phoneNumber: z.string().optional(), + companyName: z.string().optional(), + countryOfResidence: z.string().optional(), + companyWebsite: z.string().optional(), + companyPhoneNumber: z.string().optional() + }) + .refine( + (data) => { + // If business use case, company name is required + if (useCaseType === "business") { + return data.companyName; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.companyNameRequiredBusiness" + ), + path: ["companyName"] + } + ) + .refine( + (data) => { + // If business use case, country of residence is required + if (useCaseType === "business") { + return data.countryOfResidence; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.countryOfResidenceRequiredBusiness" + ), + path: ["countryOfResidence"] + } + ) + .refine( + (data) => { + // If personal use case, country is required + if (useCaseType === "personal" && !data.country) { + return false; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.countryRequiredPersonal" + ), + path: ["country"] + } + ); + + // Step 4: Terms & Generate + const step4Schema = z.object({ + agreedToTerms: z + .boolean() + .refine( + (val) => val === true, + t("generateLicenseKeyForm.validation.agreeToTermsRequired") + ), + complianceConfirmed: z + .boolean() + .refine( + (val) => val === true, + t("generateLicenseKeyForm.validation.complianceConfirmationRequired") + ) + }); + + // Complete form schema for final submission with conditional validation + const createFormSchema = (useCaseType: string | undefined) => + z + .object({ + email: z.string().email("Please enter a valid email address"), + useCaseType: z.enum(["personal", "business"]), + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + jobTitle: z.string().optional(), + primaryUse: z + .string() + .min(1, "Please describe your primary use"), + industry: z.string().optional(), + prospectiveUsers: z.coerce.number().optional(), + prospectiveSites: z.coerce.number().optional(), + stateProvinceRegion: z + .string() + .min( + 1, + t( + "generateLicenseKeyForm.validation.stateProvinceRegionRequired" + ) + ), + postalZipCode: z + .string() + .min( + 1, + t( + "generateLicenseKeyForm.validation.postalZipCodeRequired" + ) + ), + country: z.string().optional(), + phoneNumber: z.string().optional(), + companyName: z.string().optional(), + countryOfResidence: z.string().optional(), + companyWebsite: z.string().optional(), + companyPhoneNumber: z.string().optional(), + agreedToTerms: z + .boolean() + .refine( + (val) => val === true, + t( + "generateLicenseKeyForm.validation.agreeToTermsRequired" + ) + ), + complianceConfirmed: z + .boolean() + .refine( + (val) => val === true, + t("generateLicenseKeyForm.validation.complianceConfirmationRequired") + ) + }) + .refine( + (data) => { + // If business use case, job title is required + if (useCaseType === "business") { + return data.jobTitle; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.jobTitleRequiredBusiness" + ), + path: ["jobTitle"] + } + ) + .refine( + (data) => { + // If business use case, industry is required + if (useCaseType === "business") { + return data.industry; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.industryRequiredBusiness" + ), + path: ["industry"] + } + ) + .refine( + (data) => { + // If business use case, company name is required + if (useCaseType === "business") { + return data.companyName; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.companyNameRequiredBusiness" + ), + path: ["companyName"] + } + ) + .refine( + (data) => { + // If business use case, country of residence is required + if (useCaseType === "business") { + return data.countryOfResidence; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.countryOfResidenceRequiredBusiness" + ), + path: ["countryOfResidence"] + } + ) + .refine( + (data) => { + // If personal use case, country is required + if (useCaseType === "personal") { + return data.country; + } + return true; + }, + { + message: t( + "generateLicenseKeyForm.validation.countryRequiredPersonal" + ), + path: ["country"] + } + ); + + type FormData = z.infer>; + + // Base schema for form initialization (without conditional validation) + const baseFormSchema = z.object({ + email: z.string().email("Please enter a valid email address"), + useCaseType: z.enum(["personal", "business"]), + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + jobTitle: z.string().optional(), + primaryUse: z.string().min(1, "Please describe your primary use"), + industry: z.string().optional(), + prospectiveUsers: z.coerce.number().optional(), + prospectiveSites: z.coerce.number().optional(), + stateProvinceRegion: z + .string() + .min(1, "State/Province/Region is required"), + postalZipCode: z.string().min(1, "Postal/ZIP Code is required"), + country: z.string().optional(), + phoneNumber: z.string().optional(), + companyName: z.string().optional(), + countryOfResidence: z.string().optional(), + companyWebsite: z.string().optional(), + companyPhoneNumber: z.string().optional(), + agreedToTerms: z + .boolean() + .refine( + (val) => val === true, + t("generateLicenseKeyForm.validation.agreeToTermsRequired") + ), + complianceConfirmed: z + .boolean() + .refine( + (val) => val === true, + t("generateLicenseKeyForm.validation.complianceConfirmationRequired") + ) + }); + + const form = useForm({ + resolver: zodResolver(baseFormSchema), + defaultValues: { + email: user?.email || "", + useCaseType: undefined, + firstName: "", + lastName: "", + jobTitle: "", + primaryUse: "", + industry: "", + prospectiveUsers: undefined, + prospectiveSites: undefined, + stateProvinceRegion: "", + postalZipCode: "", + country: "", + phoneNumber: "", + companyName: "", + countryOfResidence: "", + companyWebsite: "", + companyPhoneNumber: "", + agreedToTerms: false, + complianceConfirmed: false + } + }); + + const useCaseType = form.watch("useCaseType"); + const [previousUseCaseType, setPreviousUseCaseType] = useState< + string | undefined + >(undefined); + + // Reset form when use case type changes + React.useEffect(() => { + if ( + useCaseType !== previousUseCaseType && + useCaseType && + previousUseCaseType + ) { + // Reset fields that are specific to use case type + form.setValue("jobTitle", ""); + form.setValue("prospectiveUsers", undefined); + form.setValue("prospectiveSites", undefined); + form.setValue("companyName", ""); + form.setValue("countryOfResidence", ""); + form.setValue("companyWebsite", ""); + form.setValue("companyPhoneNumber", ""); + form.setValue("phoneNumber", ""); + form.setValue("country", ""); + + setPreviousUseCaseType(useCaseType); + } + }, [useCaseType, previousUseCaseType, form]); + + // Reset form when dialog opens + React.useEffect(() => { + if (open) { + form.reset({ + email: user?.email || "", + useCaseType: undefined, + firstName: "", + lastName: "", + jobTitle: "", + primaryUse: "", + industry: "", + prospectiveUsers: undefined, + prospectiveSites: undefined, + stateProvinceRegion: "", + postalZipCode: "", + country: "", + phoneNumber: "", + companyName: "", + countryOfResidence: "", + companyWebsite: "", + companyPhoneNumber: "", + agreedToTerms: false, + complianceConfirmed: false + }); + setCurrentStep(1); + setGeneratedKey(null); + setPreviousUseCaseType(undefined); + } + }, [open, form, user?.email]); + + const useCaseOptions: StrategyOption<"personal" | "business">[] = [ + { + id: "personal", + title: t("generateLicenseKeyForm.useCaseOptions.personal.title"), + description: ( +
+

+ {t( + "generateLicenseKeyForm.useCaseOptions.personal.description" + )} +

+
    +
  • + + + Home-lab enthusiasts and self-hosting hobbyists + +
  • +
  • + + + Personal projects, learning, and experimentation + +
  • +
  • + + + Individual developers and tech enthusiasts + +
  • +
+
+ ) + }, + { + id: "business", + title: t("generateLicenseKeyForm.useCaseOptions.business.title"), + description: ( +
+

+ {t( + "generateLicenseKeyForm.useCaseOptions.business.description" + )} +

+
    +
  • + + + Companies, startups, and organizations + +
  • +
  • + + + Professional services and client work + +
  • +
  • + + + Revenue-generating or commercial use cases + +
  • +
+
+ ) + } + ]; + + const steps = [ + { + title: t("generateLicenseKeyForm.steps.emailLicenseType.title"), + description: t( + "generateLicenseKeyForm.steps.emailLicenseType.description" + ) + }, + { + title: t("generateLicenseKeyForm.steps.personalInformation.title"), + description: t( + "generateLicenseKeyForm.steps.personalInformation.description" + ) + }, + { + title: t("generateLicenseKeyForm.steps.contactInformation.title"), + description: t( + "generateLicenseKeyForm.steps.contactInformation.description" + ) + }, + { + title: t("generateLicenseKeyForm.steps.termsGenerate.title"), + description: t( + "generateLicenseKeyForm.steps.termsGenerate.description" + ) + } + ]; + + const nextStep = async () => { + let isValid = false; + + try { + // Validate current step based on step number + switch (currentStep) { + case 1: + await step1Schema.parseAsync(form.getValues()); + isValid = true; + break; + case 2: + await createStep2Schema( + form.getValues("useCaseType") + ).parseAsync(form.getValues()); + isValid = true; + break; + case 3: + await createStep3Schema( + form.getValues("useCaseType") + ).parseAsync(form.getValues()); + isValid = true; + break; + case 4: + await step4Schema.parseAsync(form.getValues()); + isValid = true; + break; + default: + isValid = false; + } + } catch (error) { + if (error instanceof z.ZodError) { + // Set form errors for the current step fields + error.errors.forEach((err) => { + const fieldName = err.path[0] as keyof FormData; + form.setError(fieldName, { + type: "manual", + message: err.message + }); + }); + } + return; + } + + if (isValid && currentStep < steps.length) { + setCurrentStep(currentStep + 1); + } + }; + + const prevStep = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } + }; + + const onSubmit = async (values: FormData) => { + // Validate with the dynamic schema before submission + try { + await createFormSchema(values.useCaseType).parseAsync(values); + } catch (error) { + if (error instanceof z.ZodError) { + // Set form errors for any validation failures + error.errors.forEach((err) => { + const fieldName = err.path[0] as keyof FormData; + form.setError(fieldName, { + type: "manual", + message: err.message + }); + }); + return; + } + } + + setLoading(true); + try { + const payload = { + email: values.email, + useCaseType: values.useCaseType, + personal: + values.useCaseType === "personal" + ? { + firstName: values.firstName, + lastName: values.lastName, + aboutYou: { + primaryUse: values.primaryUse + }, + personalInfo: { + stateProvinceRegion: + values.stateProvinceRegion, + postalZipCode: values.postalZipCode, + country: values.country, + phoneNumber: values.phoneNumber || "" + } + } + : undefined, + business: + values.useCaseType === "business" + ? { + firstName: values.firstName, + lastName: values.lastName, + jobTitle: values.jobTitle || "", + aboutYou: { + primaryUse: values.primaryUse, + industry: values.industry, + prospectiveUsers: + values.prospectiveUsers || undefined, + prospectiveSites: + values.prospectiveSites || undefined + }, + companyInfo: { + companyName: values.companyName || "", + countryOfResidence: + values.countryOfResidence || "", + stateProvinceRegion: + values.stateProvinceRegion, + postalZipCode: values.postalZipCode, + companyWebsite: values.companyWebsite || "", + companyPhoneNumber: + values.companyPhoneNumber || "" + } + } + : undefined, + consent: { + agreedToTerms: values.agreedToTerms, + acknowledgedPrivacyPolicy: values.agreedToTerms, + complianceConfirmed: values.complianceConfirmed + } + }; + + const response = await api.put< + AxiosResponse + >(`/org/${orgId}/license`, payload); + + if (response.data.data?.licenseKey?.licenseKey) { + setGeneratedKey(response.data.data.licenseKey.licenseKey); + onGenerated?.(); + toast({ + title: t("generateLicenseKeyForm.toasts.success.title"), + description: t( + "generateLicenseKeyForm.toasts.success.description" + ), + variant: "default" + }); + } + } catch (e) { + console.error(e); + toast({ + title: t("generateLicenseKeyForm.toasts.error.title"), + description: formatAxiosError( + e, + t("generateLicenseKeyForm.toasts.error.description") + ), + variant: "destructive" + }); + } + setLoading(false); + }; + + const handleClose = () => { + setOpen(false); + setCurrentStep(1); + setGeneratedKey(null); + setFormKey((prev) => prev + 1); // Force form reset by changing key + form.reset({ + email: user?.email || "", + useCaseType: undefined, + firstName: "", + lastName: "", + jobTitle: "", + primaryUse: "", + industry: "", + prospectiveUsers: undefined, + prospectiveSites: undefined, + stateProvinceRegion: "", + postalZipCode: "", + country: "", + phoneNumber: "", + companyName: "", + countryOfResidence: "", + companyWebsite: "", + companyPhoneNumber: "", + agreedToTerms: false, + complianceConfirmed: false + }); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 1: + return ( +
+ + + + {t( + "generateLicenseKeyForm.alerts.commercialUseDisclosure.title" + )} + + + {t( + "generateLicenseKeyForm.alerts.commercialUseDisclosure.description" + )} + + + + ( + + + {t( + "generateLicenseKeyForm.form.useCaseQuestion" + )} + + { + field.onChange(value); + // Reset form when use case type changes + form.reset({ + email: user?.email || "", + useCaseType: value, + firstName: "", + lastName: "", + jobTitle: "", + primaryUse: "", + industry: "", + prospectiveUsers: undefined, + prospectiveSites: undefined, + stateProvinceRegion: "", + postalZipCode: "", + country: "", + phoneNumber: "", + companyName: "", + countryOfResidence: "", + companyWebsite: "", + companyPhoneNumber: "", + agreedToTerms: false, + complianceConfirmed: false + }); + }} + cols={2} + /> + + + )} + /> +
+ ); + + case 2: + return ( +
+
+ ( + + + {t( + "generateLicenseKeyForm.form.firstName" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.lastName" + )} + + + + + + + )} + /> +
+ + {useCaseType === "business" && ( + ( + + + {t( + "generateLicenseKeyForm.form.jobTitle" + )} + + + + + + + )} + /> + )} + +
+ ( + + + {t( + "generateLicenseKeyForm.form.primaryUseQuestion" + )} + + + + + + + )} + /> + + {useCaseType === "business" && ( + <> + ( + + + {t( + "generateLicenseKeyForm.form.industryQuestion" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.prospectiveUsersQuestion" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.prospectiveSitesQuestion" + )} + + + + + + + )} + /> + + )} +
+
+ ); + + case 3: + return ( +
+ {useCaseType === "business" && ( +
+ ( + + + {t( + "generateLicenseKeyForm.form.companyName" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.countryOfResidence" + )} + + + + + + + )} + /> + +
+ ( + + + {t( + "generateLicenseKeyForm.form.stateProvinceRegion" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.postalZipCode" + )} + + + + + + + )} + /> +
+ +
+ ( + + + {t( + "generateLicenseKeyForm.form.companyWebsite" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.companyPhoneNumber" + )} + + + + + + + )} + /> +
+
+ )} + + {useCaseType === "personal" && ( +
+
+ ( + + + {t( + "generateLicenseKeyForm.form.stateProvinceRegion" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.postalZipCode" + )} + + + + + + + )} + /> +
+ +
+ ( + + + {t( + "generateLicenseKeyForm.form.country" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.phoneNumberOptional" + )} + + + + + + + )} + /> +
+
+ )} +
+ ); + + case 4: + return ( +
+ ( + + + + +
+ +
+ {t("signUpTerms.IAgreeToThe")}{" "} + + {t( + "signUpTerms.termsOfService" + )}{" "} + + {t("signUpTerms.and")}{" "} + + {t( + "signUpTerms.privacyPolicy" + )} + +
+
+ +
+
+ )} + /> + + ( + + + + +
+ +
+ 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. +
+
+ +
+
+ )} + /> +
+ ); + + default: + return null; + } + }; + + return ( + + + + {t("generateLicenseKey")} + + {steps[currentStep - 1]?.description} + + + +
+ {/* Progress indicator */} +
+ {steps.map((step, index) => ( +
+
+ {index + 1} +
+ + {step.title} + +
+ ))} +
+ + {generatedKey ? ( +
+ {useCaseType === "business" && ( + + + {t( + "generateLicenseKeyForm.alerts.trialPeriodInformation.title" + )} + + + {t( + "generateLicenseKeyForm.alerts.trialPeriodInformation.description" + )} + + + )} + + +
+ ) : ( +
+ + {renderStepContent()} +
+ + )} +
+
+ + + + + + {!generatedKey && ( + <> + {currentStep > 1 && ( + + )} + + {currentStep < steps.length ? ( + + ) : ( + + )} + + )} + +
+
+ ); +} diff --git a/src/components/GenerateLicenseKeysTable.tsx b/src/components/GenerateLicenseKeysTable.tsx new file mode 100644 index 00000000..374edaa5 --- /dev/null +++ b/src/components/GenerateLicenseKeysTable.tsx @@ -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[] = [ + { + accessorKey: "licenseKey", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const licenseKey = row.original.licenseKey; + return ( + + ); + } + }, + { + accessorKey: "instanceName", + cell: ({ row }) => { + return row.original.instanceName || "-"; + } + }, + { + accessorKey: "valid", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return row.original.isValid ? ( + {t("yes")} + ) : ( + {t("no")} + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const tier = row.original.tier; + return tier === "enterprise" + ? t("licenseTierEnterprise") + : t("licenseTierPersonal"); + } + }, + { + accessorKey: "terminateAt", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const termianteAt = row.original.expiresAt; + return moment(termianteAt).format("lll"); + } + } + ]; + + return ( + <> + { + setShowGenerateForm(true); + }} + /> + + + + ); +} diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index dafa31a9..04cee5c8 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -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({ />
+
- + {build === "saas" && ( +
+
+ + + + + {!isSidebarCollapsed && ( + {t("sidebarBilling")} + )} + + + + + + {!isSidebarCollapsed && ( + {t("sidebarEnterpriseLicenses")} + )} + +
+
+ )} + {build === "enterprise" && ( +
+ +
+ )} + {build === "oss" && ( +
+ +
+ )} {!isSidebarCollapsed && (
{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")}
diff --git a/src/components/LicenseKeysDataTable.tsx b/src/components/LicenseKeysDataTable.tsx index 1def304b..71b15681 100644 --- a/src/components/LicenseKeysDataTable.tsx +++ b/src/components/LicenseKeysDataTable.tsx @@ -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[] = [ @@ -42,7 +41,7 @@ export function LicenseKeysDataTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('licenseKey')} + {t("licenseKey")} ); @@ -67,13 +66,17 @@ export function LicenseKeysDataTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('valid')} + {t("valid")} ); }, cell: ({ row }) => { - return row.original.valid ? t('yes') : t('no'); + return row.original.valid ? ( + {t("yes")} + ) : ( + {t("no")} + ); } }, { @@ -86,23 +89,20 @@ export function LicenseKeysDataTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('type')} + {t("type")} ); }, 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 ? ( - {label} - ) : null; + const tier = row.original.tier; + tier === "enterprise" + ? t("licenseTierEnterprise") + : t("licenseTierPersonal"); } }, { - accessorKey: "numSites", + accessorKey: "terminateAt", header: ({ column }) => { return ( ); + }, + 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")}
) @@ -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")} /> ); } diff --git a/src/components/LicenseViolation.tsx b/src/components/LicenseViolation.tsx index ea025e4c..c5f7504d 100644 --- a/src/components/LicenseViolation.tsx +++ b/src/components/LicenseViolation.tsx @@ -32,29 +32,5 @@ export default function LicenseViolation() { ); } - // Show usage violation banner - if ( - licenseStatus.maxSites && - licenseStatus.usedSites && - licenseStatus.usedSites > licenseStatus.maxSites - ) { - return ( -
-
-

- {t('componentsLicenseViolation', {usedSites: licenseStatus.usedSites, maxSites: licenseStatus.maxSites})} -

- -
-
- ); - } - return null; } diff --git a/src/components/SidebarLicenseButton.tsx b/src/components/SidebarLicenseButton.tsx new file mode 100644 index 00000000..597b761a --- /dev/null +++ b/src/components/SidebarLicenseButton.tsx @@ -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 ? ( + + + + + + + + + Enable Enterprise License + + + + ) : ( + + + + ) + ) : null} + + ); +} diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 7e8ad336..7aaebfff 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -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,11 +108,24 @@ export function SidebarNav({ {!isCollapsed && ( <> {t(item.title)} - {item.showProfessional && !isUnlocked() && ( - - {t("licenseBadge")} + {item.isBeta && ( + + {t("beta")} )} + {build === "enterprise" && + item.showEE && + !isUnlocked() && ( + + {t("licenseBadge")} + + )} )} @@ -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, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 0c7c2b48..476fd336 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -303,7 +303,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { return (
{originalRow.exitNodeName} - {build == "saas" && originalRow.exitNodeName && + {build == "saas" && originalRow.exitNodeName && ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && ( Cloud )} diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index 63f1ce53..1b922cd1 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -7,7 +7,7 @@ import { useState, ReactNode } from "react"; export interface StrategyOption { id: TValue; title: string; - description: string; + description: string | ReactNode; disabled?: boolean; icon?: ReactNode; } @@ -68,7 +68,7 @@ export function StrategySelect({
{option.title}
- {option.description} + {typeof option.description === 'string' ? option.description : option.description}
diff --git a/src/components/private/AuthPageSettings.tsx b/src/components/private/AuthPageSettings.tsx index 46c5ede0..61e0d018 100644 --- a/src/components/private/AuthPageSettings.tsx +++ b/src/components/private/AuthPageSettings.tsx @@ -72,454 +72,475 @@ export interface AuthPageSettingsRef { hasUnsavedChanges: () => boolean; } -const AuthPageSettings = forwardRef(({ - onSaveSuccess, - onSaveError -}, ref) => { - const { org } = useOrgContext(); - const api = createApiClient(useEnvContext()); - const router = useRouter(); - const t = useTranslations(); +const AuthPageSettings = forwardRef( + ({ 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; + const subscription = useSubscriptionStatusContext(); - // Auth page domain state - const [loginPage, setLoginPage] = useState( - null - ); - const [loginPageExists, setLoginPageExists] = useState(false); - const [editDomainOpen, setEditDomainOpen] = useState(false); - const [baseDomains, setBaseDomains] = useState([]); - const [selectedDomain, setSelectedDomain] = useState<{ - domainId: string; - subdomain?: string; - fullDomain: string; - baseDomain: string; - } | null>(null); - const [loadingLoginPage, setLoadingLoginPage] = useState(true); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [loadingSave, setLoadingSave] = useState(false); + // Auth page domain state + const [loginPage, setLoginPage] = useState( + null + ); + const [loginPageExists, setLoginPageExists] = useState(false); + const [editDomainOpen, setEditDomainOpen] = useState(false); + const [baseDomains, setBaseDomains] = useState([]); + const [selectedDomain, setSelectedDomain] = useState<{ + domainId: string; + subdomain?: string; + fullDomain: string; + baseDomain: string; + } | null>(null); + const [loadingLoginPage, setLoadingLoginPage] = useState(true); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [loadingSave, setLoadingSave] = useState(false); - const form = useForm({ - resolver: zodResolver(AuthPageFormSchema), - defaultValues: { - authPageDomainId: loginPage?.domainId || "", - authPageSubdomain: loginPage?.subdomain || "" - }, - mode: "onChange" - }); + const form = useForm({ + resolver: zodResolver(AuthPageFormSchema), + defaultValues: { + authPageDomainId: loginPage?.domainId || "", + authPageSubdomain: loginPage?.subdomain || "" + }, + mode: "onChange" + }); - // Expose save function to parent component - useImperativeHandle(ref, () => ({ - saveAuthSettings: async () => { - await form.handleSubmit(onSubmit)(); - }, - hasUnsavedChanges: () => hasUnsavedChanges - }), [form, hasUnsavedChanges]); + // Expose save function to parent component + useImperativeHandle( + ref, + () => ({ + saveAuthSettings: async () => { + await form.handleSubmit(onSubmit)(); + }, + hasUnsavedChanges: () => hasUnsavedChanges + }), + [form, hasUnsavedChanges] + ); - // Fetch login page and domains data - useEffect(() => { - if (build !== "saas") { - return; - } - - const fetchLoginPage = async () => { - try { - const res = await api.get>( - `/org/${org?.org.orgId}/login-page` - ); - if (res.status === 200) { - setLoginPage(res.data.data); - setLoginPageExists(true); - // Update form with login page data - form.setValue( - "authPageDomainId", - res.data.data.domainId || "" - ); - form.setValue( - "authPageSubdomain", - res.data.data.subdomain || "" - ); - } - } catch (err) { - // Login page doesn't exist yet, that's okay - setLoginPage(null); - setLoginPageExists(false); - } finally { - setLoadingLoginPage(false); - } - }; - - const fetchDomains = async () => { - try { - const res = await api.get>( - `/org/${org?.org.orgId}/domains/` - ); - if (res.status === 200) { - const rawDomains = res.data.data.domains as DomainRow[]; - const domains = rawDomains.map((domain) => ({ - ...domain, - baseDomain: toUnicode(domain.baseDomain) - })); - setBaseDomains(domains); - } - } catch (err) { - console.error("Failed to fetch domains:", err); - } - }; - - if (org?.org.orgId) { - fetchLoginPage(); - fetchDomains(); - } - }, []); - - // Handle domain selection from modal - function handleDomainSelection(domain: { - domainId: string; - subdomain?: string; - fullDomain: string; - baseDomain: string; - }) { - form.setValue("authPageDomainId", domain.domainId); - form.setValue("authPageSubdomain", domain.subdomain || ""); - setEditDomainOpen(false); - - // Update loginPage state to show the selected domain immediately - const sanitizedSubdomain = domain.subdomain - ? finalizeSubdomainSanitize(domain.subdomain) - : ""; - - const sanitizedFullDomain = sanitizedSubdomain - ? `${sanitizedSubdomain}.${domain.baseDomain}` - : domain.baseDomain; - - // Only update loginPage state if a login page already exists - if (loginPageExists && loginPage) { - setLoginPage({ - ...loginPage, - domainId: domain.domainId, - subdomain: sanitizedSubdomain, - fullDomain: sanitizedFullDomain - }); - } - - setHasUnsavedChanges(true); - } - - // Clear auth page domain - function clearAuthPageDomain() { - form.setValue("authPageDomainId", ""); - form.setValue("authPageSubdomain", ""); - setLoginPage(null); - setHasUnsavedChanges(true); - } - - async function onSubmit(data: AuthPageFormValues) { - setLoadingSave(true); - - try { - // Handle auth page domain - if (data.authPageDomainId) { - if (build !== "saas" || (build === "saas" && subscribed)) { - const sanitizedSubdomain = data.authPageSubdomain - ? finalizeSubdomainSanitize(data.authPageSubdomain) - : ""; - - if (loginPageExists) { - // Login page exists on server - need to update it - // First, we need to get the loginPageId from the server since loginPage might be null locally - let loginPageId: number; - - if (loginPage) { - // We have the loginPage data locally - loginPageId = loginPage.loginPageId; - } else { - // User cleared selection locally, but login page still exists on server - // We need to fetch it to get the loginPageId - const fetchRes = await api.get< - AxiosResponse - >(`/org/${org?.org.orgId}/login-page`); - loginPageId = fetchRes.data.data.loginPageId; - } - - // Update existing auth page domain - const updateRes = await api.post( - `/org/${org?.org.orgId}/login-page/${loginPageId}`, - { - domainId: data.authPageDomainId, - subdomain: sanitizedSubdomain || null - } - ); - - if (updateRes.status === 201) { - setLoginPage(updateRes.data.data); - setLoginPageExists(true); - } - } else { - // No login page exists on server - create new one - const createRes = await api.put( - `/org/${org?.org.orgId}/login-page`, - { - domainId: data.authPageDomainId, - subdomain: sanitizedSubdomain || null - } - ); - - if (createRes.status === 201) { - setLoginPage(createRes.data.data); - setLoginPageExists(true); - } - } - } - } else if (loginPageExists) { - // Delete existing auth page domain if no domain selected - let loginPageId: number; - - if (loginPage) { - // We have the loginPage data locally - loginPageId = loginPage.loginPageId; - } else { - // User cleared selection locally, but login page still exists on server - // We need to fetch it to get the loginPageId - const fetchRes = await api.get< + // Fetch login page and domains data + useEffect(() => { + const fetchLoginPage = async () => { + try { + const res = await api.get< AxiosResponse >(`/org/${org?.org.orgId}/login-page`); - loginPageId = fetchRes.data.data.loginPageId; + if (res.status === 200) { + setLoginPage(res.data.data); + setLoginPageExists(true); + // Update form with login page data + form.setValue( + "authPageDomainId", + res.data.data.domainId || "" + ); + form.setValue( + "authPageSubdomain", + res.data.data.subdomain || "" + ); + } + } catch (err) { + // Login page doesn't exist yet, that's okay + setLoginPage(null); + setLoginPageExists(false); + } finally { + setLoadingLoginPage(false); } + }; - await api.delete( - `/org/${org?.org.orgId}/login-page/${loginPageId}` - ); - setLoginPage(null); - setLoginPageExists(false); + const fetchDomains = async () => { + try { + const res = await api.get< + AxiosResponse + >(`/org/${org?.org.orgId}/domains/`); + if (res.status === 200) { + const rawDomains = res.data.data.domains as DomainRow[]; + const domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain) + })); + setBaseDomains(domains); + } + } catch (err) { + console.error("Failed to fetch domains:", err); + } + }; + + if (org?.org.orgId) { + fetchLoginPage(); + fetchDomains(); + } + }, []); + + // Handle domain selection from modal + function handleDomainSelection(domain: { + domainId: string; + subdomain?: string; + fullDomain: string; + baseDomain: string; + }) { + form.setValue("authPageDomainId", domain.domainId); + form.setValue("authPageSubdomain", domain.subdomain || ""); + setEditDomainOpen(false); + + // Update loginPage state to show the selected domain immediately + const sanitizedSubdomain = domain.subdomain + ? finalizeSubdomainSanitize(domain.subdomain) + : ""; + + const sanitizedFullDomain = sanitizedSubdomain + ? `${sanitizedSubdomain}.${domain.baseDomain}` + : domain.baseDomain; + + // Only update loginPage state if a login page already exists + if (loginPageExists && loginPage) { + setLoginPage({ + ...loginPage, + domainId: domain.domainId, + subdomain: sanitizedSubdomain, + fullDomain: sanitizedFullDomain + }); } - setHasUnsavedChanges(false); - router.refresh(); - onSaveSuccess?.(); - } catch (e) { - toast({ - variant: "destructive", - title: t("authPageErrorUpdate"), - description: formatAxiosError(e, t("authPageErrorUpdateMessage")) - }); - onSaveError?.(e); - } finally { - setLoadingSave(false); + setHasUnsavedChanges(true); } - } - return ( - <> - - - - {t("authPage")} - - - {t("authPageDescription")} - - - - {build === "saas" && !subscribed ? ( - - - {t("orgAuthPageDisabled")}{" "} - {t("subscriptionRequiredToUse")} - - - ) : null} + // Clear auth page domain + function clearAuthPageDomain() { + form.setValue("authPageDomainId", ""); + form.setValue("authPageSubdomain", ""); + setLoginPage(null); + setHasUnsavedChanges(true); + } - - {loadingLoginPage ? ( -
-
- {t("loading")} + async function onSubmit(data: AuthPageFormValues) { + setLoadingSave(true); + + try { + // Handle auth page domain + if (data.authPageDomainId) { + if ( + build === "enterprise" || + (build === "saas" && subscription?.subscribed) + ) { + const sanitizedSubdomain = data.authPageSubdomain + ? finalizeSubdomainSanitize(data.authPageSubdomain) + : ""; + + if (loginPageExists) { + // Login page exists on server - need to update it + // First, we need to get the loginPageId from the server since loginPage might be null locally + let loginPageId: number; + + if (loginPage) { + // We have the loginPage data locally + loginPageId = loginPage.loginPageId; + } else { + // User cleared selection locally, but login page still exists on server + // We need to fetch it to get the loginPageId + const fetchRes = await api.get< + AxiosResponse + >(`/org/${org?.org.orgId}/login-page`); + loginPageId = fetchRes.data.data.loginPageId; + } + + // Update existing auth page domain + const updateRes = await api.post( + `/org/${org?.org.orgId}/login-page/${loginPageId}`, + { + domainId: data.authPageDomainId, + subdomain: sanitizedSubdomain || null + } + ); + + if (updateRes.status === 201) { + setLoginPage(updateRes.data.data); + setLoginPageExists(true); + } + } else { + // No login page exists on server - create new one + const createRes = await api.put( + `/org/${org?.org.orgId}/login-page`, + { + domainId: data.authPageDomainId, + subdomain: sanitizedSubdomain || null + } + ); + + if (createRes.status === 201) { + setLoginPage(createRes.data.data); + setLoginPageExists(true); + } + } + } + } else if (loginPageExists) { + // Delete existing auth page domain if no domain selected + let loginPageId: number; + + if (loginPage) { + // We have the loginPage data locally + loginPageId = loginPage.loginPageId; + } else { + // User cleared selection locally, but login page still exists on server + // We need to fetch it to get the loginPageId + const fetchRes = await api.get< + AxiosResponse + >(`/org/${org?.org.orgId}/login-page`); + loginPageId = fetchRes.data.data.loginPageId; + } + + await api.delete( + `/org/${org?.org.orgId}/login-page/${loginPageId}` + ); + setLoginPage(null); + setLoginPageExists(false); + } + + setHasUnsavedChanges(false); + router.refresh(); + onSaveSuccess?.(); + } catch (e) { + toast({ + variant: "destructive", + title: t("authPageErrorUpdate"), + description: formatAxiosError( + e, + t("authPageErrorUpdateMessage") + ) + }); + onSaveError?.(e); + } finally { + setLoadingSave(false); + } + } + + return ( + <> + + + + {t("authPage")} + + + {t("authPageDescription")} + + + + {build === "saas" && !subscription?.subscribed ? ( + + + {t("orgAuthPageDisabled")}{" "} + {t("subscriptionRequiredToUse")} + + + ) : null} + + + {loadingLoginPage ? ( +
+
+ {t("loading")} +
-
- ) : ( -
- -
- -
- - - {loginPage && - !loginPage.domainId ? ( - - ) : loginPage?.fullDomain ? ( - - {`${window.location.protocol}//${loginPage.fullDomain}`} - - ) : form.watch( - "authPageDomainId" - ) ? ( - // Show selected domain from form state when no loginPage exists yet - (() => { - const selectedDomainId = - form.watch( - "authPageDomainId" + ) : ( + + +
+ +
+ + + {loginPage && + !loginPage.domainId ? ( + + ) : loginPage?.fullDomain ? ( + + {`${window.location.protocol}//${loginPage.fullDomain}`} + + ) : form.watch( + "authPageDomainId" + ) ? ( + // Show selected domain from form state when no loginPage exists yet + (() => { + const selectedDomainId = + form.watch( + "authPageDomainId" + ); + const selectedSubdomain = + form.watch( + "authPageSubdomain" + ); + const domain = + baseDomains.find( + (d) => + d.domainId === + selectedDomainId + ); + if (domain) { + const sanitizedSubdomain = + selectedSubdomain + ? finalizeSubdomainSanitize( + selectedSubdomain + ) + : ""; + const fullDomain = + sanitizedSubdomain + ? `${sanitizedSubdomain}.${domain.baseDomain}` + : domain.baseDomain; + return fullDomain; + } + return t( + "noDomainSet" ); - const selectedSubdomain = - form.watch( - "authPageSubdomain" - ); - const domain = - baseDomains.find( - (d) => - d.domainId === - selectedDomainId - ); - if (domain) { - const sanitizedSubdomain = - selectedSubdomain - ? finalizeSubdomainSanitize( - selectedSubdomain - ) - : ""; - const fullDomain = - sanitizedSubdomain - ? `${sanitizedSubdomain}.${domain.baseDomain}` - : domain.baseDomain; - return fullDomain; - } - return t("noDomainSet"); - })() - ) : ( - t("noDomainSet") - )} - -
- - {form.watch("authPageDomainId") && ( + })() + ) : ( + t("noDomainSet") + )} + +
- )} + {form.watch( + "authPageDomainId" + ) && ( + + )} +
-
- {/* Certificate Status */} - {(build !== "saas" || - (build === "saas" && subscribed)) && - loginPage?.domainId && - loginPage?.fullDomain && - !hasUnsavedChanges && ( - + {/* Certificate Status */} + {(build === "enterprise" || + (build === "saas" && + subscription?.subscribed)) && + loginPage?.domainId && + loginPage?.fullDomain && + !hasUnsavedChanges && ( + + )} + + {!form.watch( + "authPageDomainId" + ) && ( +
+ {t( + "addDomainToEnableCustomAuthPages" + )} +
)} +
+ + + )} + + + - {!form.watch("authPageDomainId") && ( -
- {t( - "addDomainToEnableCustomAuthPages" - )} -
- )} -
- - - )} - - - + {/* Domain Picker Modal */} + setEditDomainOpen(setOpen)} + > + + + + {loginPage + ? t("editAuthPageDomain") + : t("setAuthPageDomain")} + + + {t("selectDomainForOrgAuthPage")} + + + + { + const selected = { + domainId: res.domainId, + subdomain: res.subdomain, + fullDomain: res.fullDomain, + baseDomain: res.baseDomain + }; + setSelectedDomain(selected); + }} + /> + + + + + + + + + + + ); + } +); - {/* Domain Picker Modal */} - setEditDomainOpen(setOpen)} - > - - - - {loginPage - ? t("editAuthPageDomain") - : t("setAuthPageDomain")} - - - {t("selectDomainForOrgAuthPage")} - - - - { - const selected = { - domainId: res.domainId, - subdomain: res.subdomain, - fullDomain: res.fullDomain, - baseDomain: res.baseDomain - }; - setSelectedDomain(selected); - }} - /> - - - - - - - - - - - ); -}); +AuthPageSettings.displayName = "AuthPageSettings"; -AuthPageSettings.displayName = 'AuthPageSettings'; - -export default AuthPageSettings; \ No newline at end of file +export default AuthPageSettings; diff --git a/src/contexts/subscriptionStatusContext.ts b/src/contexts/subscriptionStatusContext.ts index 267c58af..687a96bc 100644 --- a/src/contexts/subscriptionStatusContext.ts +++ b/src/contexts/subscriptionStatusContext.ts @@ -6,6 +6,8 @@ type SubscriptionStatusContextType = { updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void; isActive: () => boolean; getTier: () => string | null; + isSubscribed: () => boolean; + subscribed: boolean; }; const SubscriptionStatusContext = createContext< diff --git a/src/providers/LicenseStatusProvider.tsx b/src/providers/LicenseStatusProvider.tsx index 1f8d99c8..1196128d 100644 --- a/src/providers/LicenseStatusProvider.tsx +++ b/src/providers/LicenseStatusProvider.tsx @@ -40,13 +40,6 @@ export function LicenseStatusProvider({ ) { return true; } - if ( - licenseStatusState?.maxSites && - licenseStatusState?.usedSites && - licenseStatusState.usedSites > licenseStatusState.maxSites - ) { - return true; - } return false; }; diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index 71a9401c..d85193b2 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -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(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(isSubscribed()); + return ( {children} @@ -68,4 +84,4 @@ export function PrivateSubscriptionStatusProvider({ ); } -export default PrivateSubscriptionStatusProvider; +export default SubscriptionStatusProvider; diff --git a/tsconfig.json b/tsconfig.json index e32eabd3..0b856fe0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,7 @@ "#private/*": ["../server/private/*"], "#open/*": ["../server/*"], "#closed/*": ["../server/private/*"], - "#dynamic/*": ["../server/*"] + "#dynamic/*": ["../server/private/*"] }, "plugins": [ {