diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus.tsx new file mode 100644 index 0000000000..106c3a3509 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { TFunction } from "i18next"; +import { RotateCcwIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { recheckLicenseAction } from "@/modules/ee/license-check/actions"; +import { Alert, AlertDescription } from "@/modules/ui/components/alert"; +import { Badge } from "@/modules/ui/components/badge"; +import { Button } from "@/modules/ui/components/button"; +import { SettingsCard } from "../../../components/SettingsCard"; + +type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license"; + +interface EnterpriseLicenseStatusProps { + status: LicenseStatus; + gracePeriodEnd?: Date; + environmentId: string; +} + +const getBadgeConfig = ( + status: LicenseStatus, + t: TFunction +): { type: "success" | "error" | "warning" | "gray"; label: string } => { + switch (status) { + case "active": + return { type: "success", label: t("environments.settings.enterprise.license_status_active") }; + case "expired": + return { type: "error", label: t("environments.settings.enterprise.license_status_expired") }; + case "unreachable": + return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") }; + case "invalid_license": + return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") }; + default: + return { type: "gray", label: t("environments.settings.enterprise.license_status") }; + } +}; + +export const EnterpriseLicenseStatus = ({ status, gracePeriodEnd, environmentId }: EnterpriseLicenseStatusProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const [isRechecking, setIsRechecking] = useState(false); + + const handleRecheck = async () => { + setIsRechecking(true); + try { + const result = await recheckLicenseAction({ environmentId }); + if (result?.serverError) { + toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed")); + return; + } + + if (result?.data) { + if (result.data.status === "unreachable") { + toast.error(t("environments.settings.enterprise.recheck_license_unreachable")); + } else if (result.data.status === "invalid_license") { + toast.error(t("environments.settings.enterprise.recheck_license_invalid")); + } else { + toast.success(t("environments.settings.enterprise.recheck_license_success")); + } + router.refresh(); + } else { + toast.error(t("environments.settings.enterprise.recheck_license_failed")); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed") + ); + } finally { + setIsRechecking(false); + } + }; + + const badgeConfig = getBadgeConfig(status, t); + + return ( + +
+
+
+ +
+ +
+ {status === "unreachable" && gracePeriodEnd && ( + + + {t("environments.settings.enterprise.license_unreachable_grace_period", { + gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }), + })} + + + )} + {status === "invalid_license" && ( + + + {t("environments.settings.enterprise.license_invalid_description")} + + + )} +

+ {t("environments.settings.enterprise.questions_please_reach_out_to")}{" "} + + hola@formbricks.com + +

+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx index a515bcd75f..9252c58e3b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx @@ -2,9 +2,10 @@ import { CheckIcon } from "lucide-react"; import Link from "next/link"; import { notFound } from "next/navigation"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; +import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getTranslate } from "@/lingodotdev/server"; -import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; +import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; @@ -25,7 +26,8 @@ const Page = async (props) => { return notFound(); } - const { active: isEnterpriseEdition } = await getEnterpriseLicense(); + const licenseState = await getEnterpriseLicense(); + const hasLicense = licenseState.status !== "no-license"; const paidFeatures = [ { @@ -90,35 +92,22 @@ const Page = async (props) => { activeId="enterprise" /> - {isEnterpriseEdition ? ( -
-
-
-
-
- -
-

- {t( - "environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked" - )} -

-
-

- {t("environments.settings.enterprise.questions_please_reach_out_to")}{" "} - - hola@formbricks.com - -

-
-
-
+ {hasLicense ? ( + ) : (
    - {paidFeatures.map((feature, index) => ( -
  • + {paidFeatures.map((feature) => ( +
  • diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index a5432d016b..83fe4ba338 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -897,11 +897,25 @@ checksums: environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12 environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44 environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a + environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526 + environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8 + environments/settings/enterprise/license_status_active: 3e1ec025c4a50830bbb9ad57a176630a + environments/settings/enterprise/license_status_description: 828e4527f606471cd8cf58b55ff824f6 + environments/settings/enterprise/license_status_expired: 63b27cccba4ab2143e0f5f3d46e4168a + environments/settings/enterprise/license_status_invalid: a4bfd3787fc0bf0a38db61745bd25cec + environments/settings/enterprise/license_status_unreachable: 202b110dab099f1167b13c326349e570 + environments/settings/enterprise/license_unreachable_grace_period: c0587c9d79ac55ff2035fb8b8eec4433 environments/settings/enterprise/no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form: daef55124d4363526008eb91a0b68246 environments/settings/enterprise/no_credit_card_no_sales_call_just_test_it: 18f9859cdf12537b7019ecdb0a0a2b53 environments/settings/enterprise/on_request: cf9949748c15313a8fd57bf965bec16b environments/settings/enterprise/organization_roles: 731d5028521c2a3a7bdbd7ed215dd861 environments/settings/enterprise/questions_please_reach_out_to: ac4be65ffef9349eaeb137c254d3fee7 + environments/settings/enterprise/recheck_license: b913b64f89df184b5059710f4a0b26fa + environments/settings/enterprise/recheck_license_failed: dd410acbb8887625cf194189f832dd7c + environments/settings/enterprise/recheck_license_invalid: 58f41bc208692b7d53b975dfcf9f4ad8 + environments/settings/enterprise/recheck_license_success: 700ddd805be904a415f614de3df1da78 + environments/settings/enterprise/recheck_license_unreachable: 0ca81bd89595a9da24bc94dcef132175 + environments/settings/enterprise/rechecking: 54c454aa8e4d27363543349b7c2a28bc environments/settings/enterprise/request_30_day_trial_license: 8d5a1b5d9f0790783693122ac30c16ef environments/settings/enterprise/saml_sso: 86b76024524fc585b2c3950126ef6f62 environments/settings/enterprise/service_level_agreement: e31e74f66f5c7c79e82878f4f68abc37 @@ -909,7 +923,6 @@ checksums: environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112 environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43 - environments/settings/enterprise/your_enterprise_license_is_active_all_features_unlocked: f03f3c7a81f61eb5cd78fa7ad32896f8 environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37 environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 1035014e84..7d16f116d7 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -956,19 +956,32 @@ "enterprise_features": "Unternehmensfunktionen", "get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.", "keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.", + "license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.", + "license_status": "Lizenzstatus", + "license_status_active": "Aktiv", + "license_status_description": "Status deiner Enterprise-Lizenz.", + "license_status_expired": "Abgelaufen", + "license_status_invalid": "Ungültige Lizenz", + "license_status_unreachable": "Nicht erreichbar", + "license_unreachable_grace_period": "Der Lizenzserver ist nicht erreichbar. Deine Enterprise-Funktionen bleiben während einer 3-tägigen Kulanzfrist bis zum {gracePeriodEnd} aktiv.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Ganz unkompliziert: Fordere eine kostenlose 30-Tage-Testlizenz an, um alle Funktionen zu testen, indem Du dieses Formular ausfüllst:", "no_credit_card_no_sales_call_just_test_it": "Keine Kreditkarte. Kein Verkaufsgespräch. Einfach testen :)", "on_request": "Auf Anfrage", "organization_roles": "Organisationsrollen (Admin, Editor, Entwickler, etc.)", "questions_please_reach_out_to": "Fragen? Bitte melde Dich bei", + "recheck_license": "Lizenz erneut prüfen", + "recheck_license_failed": "Lizenzprüfung fehlgeschlagen. Der Lizenzserver ist möglicherweise nicht erreichbar.", + "recheck_license_invalid": "Der Lizenzschlüssel ist ungültig. Bitte überprüfe deinen ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Lizenzprüfung erfolgreich", + "recheck_license_unreachable": "Lizenzserver ist nicht erreichbar. Bitte versuche es später erneut.", + "rechecking": "Wird erneut geprüft...", "request_30_day_trial_license": "30-Tage-Testlizenz anfordern", "saml_sso": "SAML-SSO", "service_level_agreement": "Service-Level-Vereinbarung", "soc2_hipaa_iso_27001_compliance_check": "SOC2-, HIPAA- und ISO 27001-Konformitätsprüfung", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Teams & Zugriffskontrolle (Lesen, Lesen & Schreiben, Verwalten)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos.", - "your_enterprise_license_is_active_all_features_unlocked": "Deine Unternehmenslizenz ist aktiv. Alle Funktionen freigeschaltet." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos." }, "general": { "bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 6ce990bac7..2aeb567e5d 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -956,19 +956,32 @@ "enterprise_features": "Enterprise Features", "get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.", "keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.", + "license_status": "License Status", + "license_status_active": "Active", + "license_status_description": "Status of your enterprise license.", + "license_status_expired": "Expired", + "license_status_invalid": "Invalid License", + "license_status_unreachable": "Unreachable", + "license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.", + "license_unreachable_grace_period": "License server cannot be reached. Your enterprise features remain active during a 3-day grace period ending {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "No call needed, no strings attached: Request a free 30-day trial license to test all features by filling out this form:", "no_credit_card_no_sales_call_just_test_it": "No credit card. No sales call. Just test it :)", "on_request": "On request", "organization_roles": "Organization Roles (Admin, Editor, Developer, etc.)", "questions_please_reach_out_to": "Questions? Please reach out to", + "recheck_license": "Recheck license", + "recheck_license_failed": "License check failed. The license server may be unreachable.", + "recheck_license_invalid": "The license key is invalid. Please verify your ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "License check successful", + "recheck_license_unreachable": "License server is unreachable. Please try again later.", + "rechecking": "Rechecking...", "request_30_day_trial_license": "Request 30-day Trial License", "saml_sso": "SAML SSO", "service_level_agreement": "Service Level Agreement", "soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 Compliance check", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Teams & Access Roles (Read, Read & Write, Manage)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days.", - "your_enterprise_license_is_active_all_features_unlocked": "Your Enterprise License is active. All features unlocked." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days." }, "general": { "bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 839ab6c4e2..f191b446ed 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -956,19 +956,32 @@ "enterprise_features": "Características empresariales", "get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.", "keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.", + "license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.", + "license_status": "Estado de la licencia", + "license_status_active": "Activa", + "license_status_description": "Estado de tu licencia enterprise.", + "license_status_expired": "Caducada", + "license_status_invalid": "Licencia no válida", + "license_status_unreachable": "Inaccesible", + "license_unreachable_grace_period": "No se puede acceder al servidor de licencias. Tus funciones empresariales permanecen activas durante un período de gracia de 3 días que finaliza el {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Sin necesidad de llamadas, sin compromisos: solicita una licencia de prueba gratuita de 30 días para probar todas las características rellenando este formulario:", "no_credit_card_no_sales_call_just_test_it": "Sin tarjeta de crédito. Sin llamada de ventas. Solo pruébalo :)", "on_request": "Bajo petición", "organization_roles": "Roles de organización (administrador, editor, desarrollador, etc.)", "questions_please_reach_out_to": "¿Preguntas? Por favor, contacta con", + "recheck_license": "Volver a comprobar licencia", + "recheck_license_failed": "Error al comprobar la licencia. Es posible que el servidor de licencias no esté disponible.", + "recheck_license_invalid": "La clave de licencia no es válida. Por favor, verifica tu ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Comprobación de licencia correcta", + "recheck_license_unreachable": "El servidor de licencias no está disponible. Inténtalo de nuevo más tarde.", + "rechecking": "Comprobando...", "request_30_day_trial_license": "Solicitar licencia de prueba de 30 días", "saml_sso": "SAML SSO", "service_level_agreement": "Acuerdo de nivel de servicio", "soc2_hipaa_iso_27001_compliance_check": "Verificación de cumplimiento SOC2, HIPAA, ISO 27001", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Equipos y roles de acceso (lectura, lectura y escritura, gestión)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días.", - "your_enterprise_license_is_active_all_features_unlocked": "Tu licencia empresarial está activa. Todas las características desbloqueadas." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días." }, "general": { "bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 71c4d4bf7d..f989e8197d 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -956,19 +956,32 @@ "enterprise_features": "Fonctionnalités d'entreprise", "get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.", "keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.", + "license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.", + "license_status": "Statut de la licence", + "license_status_active": "Active", + "license_status_description": "Statut de votre licence entreprise.", + "license_status_expired": "Expirée", + "license_status_invalid": "Licence invalide", + "license_status_unreachable": "Inaccessible", + "license_unreachable_grace_period": "Le serveur de licence est injoignable. Vos fonctionnalités entreprise restent actives pendant une période de grâce de 3 jours se terminant le {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Aucun appel nécessaire, aucune obligation : Demandez une licence d'essai gratuite de 30 jours pour tester toutes les fonctionnalités en remplissant ce formulaire :", "no_credit_card_no_sales_call_just_test_it": "Aucune carte de crédit. Aucun appel de vente. Testez-le simplement :)", "on_request": "Sur demande", "organization_roles": "Rôles d'organisation (Administrateur, Éditeur, Développeur, etc.)", "questions_please_reach_out_to": "Des questions ? Veuillez contacter", + "recheck_license": "Revérifier la licence", + "recheck_license_failed": "La vérification de la licence a échoué. Le serveur de licences est peut-être inaccessible.", + "recheck_license_invalid": "La clé de licence est invalide. Veuillez vérifier votre ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Vérification de la licence réussie", + "recheck_license_unreachable": "Le serveur de licences est inaccessible. Veuillez réessayer plus tard.", + "rechecking": "Revérification en cours...", "request_30_day_trial_license": "Demander une licence d'essai de 30 jours", "saml_sso": "SAML SSO", "service_level_agreement": "Accord de niveau de service", "soc2_hipaa_iso_27001_compliance_check": "Vérification de conformité SOC2, HIPAA, ISO 27001", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Équipes et Rôles d'Accès (Lire, Lire et Écrire, Gérer)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours.", - "your_enterprise_license_is_active_all_features_unlocked": "Votre licence d'entreprise est active. Toutes les fonctionnalités sont déverrouillées." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours." }, "general": { "bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index d2db0bf728..9a65961201 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -956,19 +956,32 @@ "enterprise_features": "Vállalati funkciók", "get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.", "keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.", + "license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban található licenckulcs nem érvényes. Kérjük, ellenőrizd az elgépeléseket, vagy kérj új kulcsot.", + "license_status": "Licenc állapota", + "license_status_active": "Aktív", + "license_status_description": "A vállalati licenced állapota.", + "license_status_expired": "Lejárt", + "license_status_invalid": "Érvénytelen licenc", + "license_status_unreachable": "Nem elérhető", + "license_unreachable_grace_period": "A licenckiszolgáló nem érhető el. A vállalati funkciók a 3 napos türelmi időszak alatt aktívak maradnak, amely {gracePeriodEnd} időpontban jár le.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos ingyenes próbalicencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:", "no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)", "on_request": "Kérésre", "organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)", "questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:", + "recheck_license": "Licenc újraellenőrzése", + "recheck_license_failed": "A licenc ellenőrzése sikertelen. Előfordulhat, hogy a licenckiszolgáló nem elérhető.", + "recheck_license_invalid": "A licenckulcs érvénytelen. Kérjük, ellenőrizd az ENTERPRISE_LICENSE_KEY értékét.", + "recheck_license_success": "A licenc ellenőrzése sikeres", + "recheck_license_unreachable": "A licenckiszolgáló nem elérhető. Kérjük, próbáld újra később.", + "rechecking": "Újraellenőrzés...", "request_30_day_trial_license": "30 napos ingyenes licenc kérése", "saml_sso": "SAML SSO", "service_level_agreement": "Szolgáltatási megállapodás", "soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés", "sso": "SSO (Google, Microsoft, OpenID-kapcsolat)", "teams": "Csapatok és hozzáférési szerepek (olvasás, olvasás és írás, kezelés)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen.", - "your_enterprise_license_is_active_all_features_unlocked": "A vállalati licence aktív. Az összes funkció feloldva." + "unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen." }, "general": { "bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 95293c7071..c5157d3c9e 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -956,19 +956,32 @@ "enterprise_features": "エンタープライズ機能", "get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。", "keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。", + "license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。", + "license_status": "ライセンスステータス", + "license_status_active": "有効", + "license_status_description": "エンタープライズライセンスのステータス。", + "license_status_expired": "期限切れ", + "license_status_invalid": "無効なライセンス", + "license_status_unreachable": "接続不可", + "license_unreachable_grace_period": "ライセンスサーバーに接続できません。エンタープライズ機能は{gracePeriodEnd}までの3日間の猶予期間中は引き続き利用できます。", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "電話不要、制約なし: このフォームに記入して、すべての機能をテストするための無料の30日間トライアルライセンスをリクエストしてください:", "no_credit_card_no_sales_call_just_test_it": "クレジットカード不要。営業電話もありません。ただテストしてください :)", "on_request": "リクエストに応じて", "organization_roles": "組織ロール(管理者、編集者、開発者など)", "questions_please_reach_out_to": "質問はありますか?こちらまでお問い合わせください", + "recheck_license": "ライセンスを再確認", + "recheck_license_failed": "ライセンスの確認に失敗しました。ライセンスサーバーに接続できない可能性があります。", + "recheck_license_invalid": "ライセンスキーが無効です。ENTERPRISE_LICENSE_KEYを確認してください。", + "recheck_license_success": "ライセンスの確認に成功しました", + "recheck_license_unreachable": "ライセンスサーバーに接続できません。後ほど再度お試しください。", + "rechecking": "再確認中...", "request_30_day_trial_license": "30日間トライアルライセンスをリクエスト", "saml_sso": "SAML SSO", "service_level_agreement": "サービスレベル契約", "soc2_hipaa_iso_27001_compliance_check": "SOC2、HIPAA、ISO 27001準拠チェック", "sso": "SSO(Google、Microsoft、OpenID Connect)", "teams": "チーム&アクセスロール(読み取り、読み書き、管理)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。", - "your_enterprise_license_is_active_all_features_unlocked": "あなたのエンタープライズライセンスは有効です。すべての機能がアンロックされました。" + "unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。" }, "general": { "bulk_invite_warning_description": "無料プランでは、すべての組織メンバーに常に「オーナー」ロールが割り当てられます。", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 13157a8647..fdfc807d61 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -956,19 +956,32 @@ "enterprise_features": "Enterprise-functies", "get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.", "keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.", + "license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.", + "license_status": "Licentiestatus", + "license_status_active": "Actief", + "license_status_description": "Status van je enterprise-licentie.", + "license_status_expired": "Verlopen", + "license_status_invalid": "Ongeldige licentie", + "license_status_unreachable": "Niet bereikbaar", + "license_unreachable_grace_period": "Licentieserver is niet bereikbaar. Je enterprise functies blijven actief tijdens een respijtperiode van 3 dagen die eindigt op {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Geen telefoontje nodig, geen verplichtingen: vraag een gratis proeflicentie van 30 dagen aan om alle functies te testen door dit formulier in te vullen:", "no_credit_card_no_sales_call_just_test_it": "Geen creditcard. Geen verkoopgesprek. Gewoon testen :)", "on_request": "Op aanvraag", "organization_roles": "Organisatierollen (beheerder, redacteur, ontwikkelaar, etc.)", "questions_please_reach_out_to": "Vragen? Neem contact op met", + "recheck_license": "Licentie opnieuw controleren", + "recheck_license_failed": "Licentiecontrole mislukt. De licentieserver is mogelijk niet bereikbaar.", + "recheck_license_invalid": "De licentiesleutel is ongeldig. Controleer je ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Licentiecontrole geslaagd", + "recheck_license_unreachable": "Licentieserver is niet bereikbaar. Probeer het later opnieuw.", + "rechecking": "Opnieuw controleren...", "request_30_day_trial_license": "Vraag een proeflicentie van 30 dagen aan", "saml_sso": "SAML-SSO", "service_level_agreement": "Service Level Overeenkomst", "soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 Conformiteitscontrole", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Teams en toegangsrollen (lezen, lezen en schrijven, beheren)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis.", - "your_enterprise_license_is_active_all_features_unlocked": "Uw Enterprise-licentie is actief. Alle functies ontgrendeld." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis." }, "general": { "bulk_invite_warning_description": "Bij het gratis abonnement krijgen alle organisatieleden altijd de rol 'Eigenaar' toegewezen.", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 3942d26466..9baea06efd 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -956,19 +956,32 @@ "enterprise_features": "Recursos Empresariais", "get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.", "keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.", + "license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.", + "license_status": "Status da licença", + "license_status_active": "Ativa", + "license_status_description": "Status da sua licença enterprise.", + "license_status_expired": "Expirada", + "license_status_invalid": "Licença inválida", + "license_status_unreachable": "Inacessível", + "license_unreachable_grace_period": "O servidor de licenças não pode ser alcançado. Seus recursos empresariais permanecem ativos durante um período de carência de 3 dias que termina em {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Sem necessidade de ligação, sem compromisso: Solicite uma licença de teste gratuita de 30 dias para testar todas as funcionalidades preenchendo este formulário:", "no_credit_card_no_sales_call_just_test_it": "Sem cartão de crédito. Sem ligação de vendas. Só teste :)", "on_request": "Quando solicitado", "organization_roles": "Funções na Organização (Admin, Editor, Desenvolvedor, etc.)", "questions_please_reach_out_to": "Perguntas? Entre em contato com", + "recheck_license": "Verificar licença novamente", + "recheck_license_failed": "Falha na verificação da licença. O servidor de licenças pode estar inacessível.", + "recheck_license_invalid": "A chave de licença é inválida. Verifique sua ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Verificação da licença bem-sucedida", + "recheck_license_unreachable": "Servidor de licenças inacessível. Por favor, tente novamente mais tarde.", + "rechecking": "Verificando novamente...", "request_30_day_trial_license": "Pedir Licença de Teste de 30 Dias", "saml_sso": "SSO SAML", "service_level_agreement": "Acordo de Nível de Serviço", "soc2_hipaa_iso_27001_compliance_check": "Verificação de conformidade SOC2, HIPAA, ISO 27001", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Equipes e Funções de Acesso (Ler, Ler e Escrever, Gerenciar)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias.", - "your_enterprise_license_is_active_all_features_unlocked": "Sua licença empresarial está ativa. Todos os recursos estão desbloqueados." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias." }, "general": { "bulk_invite_warning_description": "Por favor, note que no Plano Gratuito, todos os membros da organização são automaticamente atribuídos ao papel de 'Owner', independentemente do papel especificado no arquivo CSV.", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 12fb3dfc3c..6a21de278a 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -956,19 +956,32 @@ "enterprise_features": "Funcionalidades da Empresa", "get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.", "keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.", + "license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.", + "license_status": "Estado da licença", + "license_status_active": "Ativa", + "license_status_description": "Estado da sua licença empresarial.", + "license_status_expired": "Expirada", + "license_status_invalid": "Licença inválida", + "license_status_unreachable": "Inacessível", + "license_unreachable_grace_period": "Não é possível contactar o servidor de licenças. As suas funcionalidades empresariais permanecem ativas durante um período de tolerância de 3 dias que termina a {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Sem necessidade de chamada, sem compromissos: Solicite uma licença de teste gratuita de 30 dias para testar todas as funcionalidades preenchendo este formulário:", "no_credit_card_no_sales_call_just_test_it": "Sem cartão de crédito. Sem chamada de vendas. Apenas teste :)", "on_request": "A pedido", "organization_roles": "Funções da Organização (Administrador, Editor, Programador, etc.)", "questions_please_reach_out_to": "Questões? Por favor entre em contacto com", + "recheck_license": "Verificar licença novamente", + "recheck_license_failed": "A verificação da licença falhou. O servidor de licenças pode estar inacessível.", + "recheck_license_invalid": "A chave de licença é inválida. Por favor, verifique a sua ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Verificação da licença bem-sucedida", + "recheck_license_unreachable": "O servidor de licenças está inacessível. Por favor, tenta novamente mais tarde.", + "rechecking": "A verificar novamente...", "request_30_day_trial_license": "Solicitar Licença de Teste de 30 Dias", "saml_sso": "SSO SAML", "service_level_agreement": "Acordo de Nível de Serviço", "soc2_hipaa_iso_27001_compliance_check": "Verificação de conformidade SOC2, HIPAA, ISO 27001", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Equipas e Funções de Acesso (Ler, Ler e Escrever, Gerir)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias.", - "your_enterprise_license_is_active_all_features_unlocked": "A sua licença Enterprise está ativa. Todas as funcionalidades desbloqueadas." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias." }, "general": { "bulk_invite_warning_description": "No plano gratuito, todos os membros da organização são sempre atribuídos ao papel de \"Proprietário\".", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index c5125e74e4..beccd68adc 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -956,19 +956,32 @@ "enterprise_features": "Funcții Enterprise", "get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.", "keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.", + "license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.", + "license_status": "Stare licență", + "license_status_active": "Activă", + "license_status_description": "Starea licenței tale enterprise.", + "license_status_expired": "Expirată", + "license_status_invalid": "Licență invalidă", + "license_status_unreachable": "Indisponibilă", + "license_unreachable_grace_period": "Serverul de licențe nu poate fi contactat. Funcționalitățile enterprise rămân active timp de 3 zile, până la data de {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nicio apel necesar, fără obligații: Solicitați o licență de probă gratuită de 30 de zile pentru a testa toate funcțiile prin completarea acestui formular:", "no_credit_card_no_sales_call_just_test_it": "Nu este nevoie de card de credit. Fără apeluri de vânzări. Doar testează-l :)", "on_request": "La cerere", "organization_roles": "Roluri organizaționale (Administrator, Editor, Dezvoltator, etc.)", "questions_please_reach_out_to": "Întrebări? Vă rugăm să trimiteți mesaj către", + "recheck_license": "Verifică din nou licența", + "recheck_license_failed": "Verificarea licenței a eșuat. Serverul de licențe poate fi indisponibil.", + "recheck_license_invalid": "Cheia de licență este invalidă. Te rugăm să verifici variabila ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Licența a fost verificată cu succes", + "recheck_license_unreachable": "Serverul de licențe este indisponibil. Te rugăm să încerci din nou mai târziu.", + "rechecking": "Se verifică din nou...", "request_30_day_trial_license": "Solicitați o licență de încercare de 30 de zile", "saml_sso": "SAML SSO", "service_level_agreement": "Acord privind nivelul de servicii", "soc2_hipaa_iso_27001_compliance_check": "Verificare conformitate SOC2, HIPAA, ISO 27001", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Echipe & Roluri de Acces (Citiți, Citiți și Scrieți, Gestionați)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile.", - "your_enterprise_license_is_active_all_features_unlocked": "Licența dvs. Enterprise este activă. Toate funcțiile sunt deblocate." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile." }, "general": { "bulk_invite_warning_description": "În planul gratuit, toți membrii organizației sunt întotdeauna alocați rolului „Proprietar”.", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 97bb25c90a..cbcc4e6750 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -956,19 +956,32 @@ "enterprise_features": "Функции для предприятий", "get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.", "keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.", + "license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.", + "license_status": "Статус лицензии", + "license_status_active": "Активна", + "license_status_description": "Статус вашей корпоративной лицензии.", + "license_status_expired": "Срок действия истёк", + "license_status_invalid": "Недействительная лицензия", + "license_status_unreachable": "Недоступна", + "license_unreachable_grace_period": "Не удаётся подключиться к серверу лицензий. Корпоративные функции останутся активными в течение 3-дневного льготного периода, который закончится {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Без звонков и обязательств: запросите бесплатную 30-дневную пробную лицензию для тестирования всех функций, заполнив эту форму:", "no_credit_card_no_sales_call_just_test_it": "Без кредитной карты. Без звонков от отдела продаж. Просто попробуйте :)", "on_request": "По запросу", "organization_roles": "Роли в организации (администратор, редактор, разработчик и др.)", "questions_please_reach_out_to": "Вопросы? Свяжитесь с", + "recheck_license": "Проверить лицензию ещё раз", + "recheck_license_failed": "Не удалось проверить лицензию. Сервер лицензий может быть недоступен.", + "recheck_license_invalid": "Ключ лицензии недействителен. Пожалуйста, проверь свою переменную ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Проверка лицензии прошла успешно", + "recheck_license_unreachable": "Сервер лицензий недоступен. Пожалуйста, попробуй позже.", + "rechecking": "Проверка...", "request_30_day_trial_license": "Запросить 30-дневную пробную лицензию", "saml_sso": "SAML SSO", "service_level_agreement": "Соглашение об уровне обслуживания (SLA)", "soc2_hipaa_iso_27001_compliance_check": "Проверка соответствия SOC2, HIPAA, ISO 27001", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Команды и роли доступа (чтение, чтение и запись, управление)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Откройте все возможности Formbricks. Бесплатно на 30 дней.", - "your_enterprise_license_is_active_all_features_unlocked": "Ваша корпоративная лицензия активна. Все функции разблокированы." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Откройте все возможности Formbricks. Бесплатно на 30 дней." }, "general": { "bulk_invite_warning_description": "В бесплатном тарифе всем участникам организации всегда назначается роль \"Владелец\".", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 53c5be0d1c..e4362a205a 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -956,19 +956,32 @@ "enterprise_features": "Enterprise-funktioner", "get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.", "keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.", + "license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.", + "license_status": "Licensstatus", + "license_status_active": "Aktiv", + "license_status_description": "Status för din företagslicens.", + "license_status_expired": "Utgången", + "license_status_invalid": "Ogiltig licens", + "license_status_unreachable": "Otillgänglig", + "license_unreachable_grace_period": "Licensservern kan inte nås. Dina enterprise-funktioner är aktiva under en 3-dagars respitperiod som slutar {gracePeriodEnd}.", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Inget samtal behövs, inga åtaganden: Begär en gratis 30-dagars provlicens för att testa alla funktioner genom att fylla i detta formulär:", "no_credit_card_no_sales_call_just_test_it": "Inget kreditkort. Inget säljsamtal. Testa bara :)", "on_request": "På begäran", "organization_roles": "Organisationsroller (Admin, Redaktör, Utvecklare, etc.)", "questions_please_reach_out_to": "Frågor? Kontakta", + "recheck_license": "Kontrollera licensen igen", + "recheck_license_failed": "Licenskontrollen misslyckades. Licensservern kan vara otillgänglig.", + "recheck_license_invalid": "Licensnyckeln är ogiltig. Kontrollera din ENTERPRISE_LICENSE_KEY.", + "recheck_license_success": "Licenskontrollen lyckades", + "recheck_license_unreachable": "Licensservern är otillgänglig. Försök igen senare.", + "rechecking": "Kontrollerar igen...", "request_30_day_trial_license": "Begär 30-dagars provlicens", "saml_sso": "SAML SSO", "service_level_agreement": "Servicenivåavtal", "soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 efterlevnadskontroll", "sso": "SSO (Google, Microsoft, OpenID Connect)", "teams": "Team och åtkomstroller (Läs, Läs och skriv, Hantera)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla kraft. Gratis i 30 dagar.", - "your_enterprise_license_is_active_all_features_unlocked": "Din Enterprise-licens är aktiv. Alla funktioner upplåsta." + "unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla kraft. Gratis i 30 dagar." }, "general": { "bulk_invite_warning_description": "På gratisplanen tilldelas alla organisationsmedlemmar alltid rollen \"Ägare\".", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index a41eda24d3..a0517ec6d0 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -956,19 +956,32 @@ "enterprise_features": "企业 功能", "get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。", "keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。", + "license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。", + "license_status": "许可证状态", + "license_status_active": "已激活", + "license_status_description": "你的企业许可证状态。", + "license_status_expired": "已过期", + "license_status_invalid": "许可证无效", + "license_status_unreachable": "无法访问", + "license_unreachable_grace_period": "无法连接到许可证服务器。在为期 3 天的宽限期内,你的企业功能仍然可用,宽限期将于 {gracePeriodEnd} 结束。", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "无需 电话 ,无需 附加 条件: 申请 免费 30 天 试用 授权以 通过 填写 此 表格 测试 所有 功能:", "no_credit_card_no_sales_call_just_test_it": "无需信用卡 。无需销售电话 。只需测试一下 :)", "on_request": "按请求", "organization_roles": "组织角色(管理员,编辑,开发者等)", "questions_please_reach_out_to": "问题 ? 请 联系", + "recheck_license": "重新检查许可证", + "recheck_license_failed": "许可证检查失败。许可证服务器可能无法访问。", + "recheck_license_invalid": "许可证密钥无效。请确认你的 ENTERPRISE_LICENSE_KEY。", + "recheck_license_success": "许可证检查成功", + "recheck_license_unreachable": "许可证服务器无法访问,请稍后再试。", + "rechecking": "正在重新检查...", "request_30_day_trial_license": "申请 30 天 的 试用许可证", "saml_sso": "SAML SSO", "service_level_agreement": "服务水平协议", "soc2_hipaa_iso_27001_compliance_check": "SOC2 , HIPAA , ISO 27001 合规检查", "sso": "SSO (Google 、Microsoft 、OpenID Connect)", "teams": "团队 & 访问 角色(读取, 读取 & 写入, 管理)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。免费使用 30 天。", - "your_enterprise_license_is_active_all_features_unlocked": "您的企业许可证已激活 所有功能已解锁" + "unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。免费使用 30 天。" }, "general": { "bulk_invite_warning_description": "在免费计划中,所有组织成员都会被分配为 \"Owner \"角色。", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 11f4084c2a..21a0e5ec00 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -956,19 +956,32 @@ "enterprise_features": "企業版功能", "get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。", "keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。", + "license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。", + "license_status": "授權狀態", + "license_status_active": "有效", + "license_status_description": "你的企業授權狀態。", + "license_status_expired": "已過期", + "license_status_invalid": "授權無效", + "license_status_unreachable": "無法連線", + "license_unreachable_grace_period": "無法連線至授權伺服器。在 3 天的寬限期內,你的企業功能仍可使用,寬限期將於 {gracePeriodEnd} 結束。", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "無需通話,無附加條件:填寫此表單,請求免費 30 天試用授權以測試所有功能:", "no_credit_card_no_sales_call_just_test_it": "無需信用卡。無需銷售電話。只需測試一下 :)", "on_request": "依要求", "organization_roles": "組織角色(管理員、編輯者、開發人員等)", "questions_please_reach_out_to": "有任何問題?請聯絡", + "recheck_license": "重新檢查授權", + "recheck_license_failed": "授權檢查失敗。授權伺服器可能無法連線。", + "recheck_license_invalid": "授權金鑰無效。請確認你的 ENTERPRISE_LICENSE_KEY。", + "recheck_license_success": "授權檢查成功", + "recheck_license_unreachable": "授權伺服器無法連線,請稍後再試。", + "rechecking": "正在重新檢查...", "request_30_day_trial_license": "請求 30 天試用授權", "saml_sso": "SAML SSO", "service_level_agreement": "服務等級協定", "soc2_hipaa_iso_27001_compliance_check": "SOC2、HIPAA、ISO 27001 合規性檢查", "sso": "SSO(Google、Microsoft、OpenID Connect)", "teams": "團隊和存取角色(讀取、讀取和寫入、管理)", - "unlock_the_full_power_of_formbricks_free_for_30_days": "免費解鎖 Formbricks 的全部功能,為期 30 天。", - "your_enterprise_license_is_active_all_features_unlocked": "您的企業授權處於活動狀態。所有功能都已解鎖。" + "unlock_the_full_power_of_formbricks_free_for_30_days": "免費解鎖 Formbricks 的全部功能,為期 30 天。" }, "general": { "bulk_invite_warning_description": "在免費方案中,所有組織成員始終會被指派「擁有者」角色。", diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts index f7cd0c74b0..13b82b091f 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts @@ -73,7 +73,12 @@ describe("rateLimitConfigs", () => { test("should have all action configurations", () => { const actionConfigs = Object.keys(rateLimitConfigs.actions); - expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp", "sendLinkSurveyEmail"]); + expect(actionConfigs).toEqual([ + "emailUpdate", + "surveyFollowUp", + "sendLinkSurveyEmail", + "licenseRecheck", + ]); }); }); diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.ts index dfc3814028..3d8871ee66 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-configs.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.ts @@ -28,6 +28,7 @@ export const rateLimitConfigs = { allowedPerInterval: 10, namespace: "action:send-link-survey-email", }, // 10 per hour + licenseRecheck: { interval: 60, allowedPerInterval: 5, namespace: "action:license-recheck" }, // 5 per minute }, storage: { diff --git a/apps/web/modules/ee/license-check/actions.ts b/apps/web/modules/ee/license-check/actions.ts new file mode 100644 index 0000000000..70de3373c7 --- /dev/null +++ b/apps/web/modules/ee/license-check/actions.ts @@ -0,0 +1,100 @@ +"use server"; + +import { z } from "zod"; +import { ZId } from "@formbricks/types/common"; +import { + AuthenticationError, + OperationNotAllowedError, + ResourceNotFoundError, +} from "@formbricks/types/errors"; +import { cache } from "@/lib/cache"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { + FAILED_FETCH_TTL_MS, + FETCH_LICENSE_TTL_MS, + LicenseApiError, + clearLicenseCache, + computeFreshLicenseState, + fetchLicenseFresh, + getCacheKeys, +} from "./lib/license"; + +const ZRecheckLicenseAction = z.object({ + environmentId: ZId, +}); + +export type TRecheckLicenseAction = z.infer; + +export const recheckLicenseAction = authenticatedActionClient + .schema(ZRecheckLicenseAction) + .action( + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: TRecheckLicenseAction; + }) => { + // Rate limit: 5 rechecks per minute per user + await applyRateLimit(rateLimitConfigs.actions.licenseRecheck, ctx.user.id); + + // Only allow on self-hosted instances + if (IS_FORMBRICKS_CLOUD) { + throw new OperationNotAllowedError("License recheck is only available on self-hosted instances"); + } + + // Get organization from environment + const organization = await getOrganizationByEnvironmentId(parsedInput.environmentId); + if (!organization) { + throw new ResourceNotFoundError("Organization", null); + } + + // Check user is owner or manager (not member) + const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organization.id); + if (!currentUserMembership) { + throw new AuthenticationError("User not a member of this organization"); + } + + if (currentUserMembership.role === "member") { + throw new OperationNotAllowedError("Only owners and managers can recheck license"); + } + + // Clear main license cache (preserves previous result cache for grace period) + // This prevents instant downgrade if the license server is temporarily unreachable + await clearLicenseCache(); + + const cacheKeys = getCacheKeys(); + let freshLicense: Awaited>; + + try { + freshLicense = await fetchLicenseFresh(); + } catch (error) { + // 400 = invalid license key — return directly so the UI shows the correct message + if (error instanceof LicenseApiError && error.status === 400) { + return { active: false, status: "invalid_license" as const }; + } + throw error; + } + + // Cache the fresh result (or null if failed) so getEnterpriseLicense can use it. + // Wrapped in { value: ... } so fetchLicense can distinguish cache miss from cached null. + if (freshLicense) { + await cache.set(cacheKeys.FETCH_LICENSE_CACHE_KEY, { value: freshLicense }, FETCH_LICENSE_TTL_MS); + } else { + await cache.set(cacheKeys.FETCH_LICENSE_CACHE_KEY, { value: null }, FAILED_FETCH_TTL_MS); + } + + const licenseState = await computeFreshLicenseState(freshLicense); + + return { + active: licenseState.active, + status: licenseState.status, + }; + } + ); diff --git a/apps/web/modules/ee/license-check/lib/license.test.ts b/apps/web/modules/ee/license-check/lib/license.test.ts index d7478ec373..90b8145505 100644 --- a/apps/web/modules/ee/license-check/lib/license.test.ts +++ b/apps/web/modules/ee/license-check/lib/license.test.ts @@ -98,6 +98,7 @@ describe("License Core Logic", () => { mockCache.get.mockReset(); mockCache.set.mockReset(); mockCache.del.mockReset(); + mockCache.exists.mockReset(); mockCache.withCache.mockReset(); mockLogger.error.mockReset(); mockLogger.warn.mockReset(); @@ -105,9 +106,10 @@ describe("License Core Logic", () => { mockLogger.debug.mockReset(); // Set up default mock implementations for Result types + // fetchLicense uses get + exists; getPreviousResult uses get with :previous_result key mockCache.get.mockResolvedValue({ ok: true, data: null }); + mockCache.exists.mockResolvedValue({ ok: true, data: false }); // default: cache miss mockCache.set.mockResolvedValue({ ok: true }); - mockCache.withCache.mockImplementation(async (fn) => await fn()); vi.mocked(prisma.response.count).mockResolvedValue(100); vi.mocked(prisma.organization.findFirst).mockResolvedValue({ @@ -164,16 +166,20 @@ describe("License Core Logic", () => { const { getEnterpriseLicense } = await import("./license"); const fetch = (await import("node-fetch")).default as Mock; - // Mock cache.withCache to return cached license details (simulating cache hit) - mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails); + // Mock cache hit: get returns wrapped license for status key + mockCache.get.mockImplementation(async (key: string) => { + if (key.includes(":previous_result")) { + return { ok: true, data: null }; + } + if (key.includes(":status")) { + return { ok: true, data: { value: mockFetchedLicenseDetails } }; + } + return { ok: true, data: null }; + }); const license = await getEnterpriseLicense(); expect(license).toEqual(expectedActiveLicenseState); - expect(mockCache.withCache).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining("fb:license:"), - expect.any(Number) - ); + expect(mockCache.get).toHaveBeenCalledWith(expect.stringContaining("fb:license:")); expect(fetch).not.toHaveBeenCalled(); }); @@ -181,9 +187,7 @@ describe("License Core Logic", () => { const { getEnterpriseLicense } = await import("./license"); const fetch = (await import("node-fetch")).default as Mock; - // Mock cache.withCache to execute the function (simulating cache miss) - mockCache.withCache.mockImplementation(async (fn) => await fn()); - + // Default mocks give cache miss (get returns null) fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: mockFetchedLicenseDetails }), @@ -192,11 +196,7 @@ describe("License Core Logic", () => { const license = await getEnterpriseLicense(); expect(fetch).toHaveBeenCalledTimes(1); - expect(mockCache.withCache).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining("fb:license:"), - expect.any(Number) - ); + expect(mockCache.get).toHaveBeenCalledWith(expect.stringContaining("fb:license:")); expect(license).toEqual(expectedActiveLicenseState); }); @@ -212,11 +212,9 @@ describe("License Core Logic", () => { version: 1, }; - // Mock cache.withCache to return null (simulating fetch failure) - mockCache.withCache.mockResolvedValue(null); - - // Mock cache.get to return previous result when requested - mockCache.get.mockImplementation(async (key) => { + // Cache miss for fetch (get null, exists false) -> fetch fails -> null + // getPreviousResult returns previous result for :previous_result key + mockCache.get.mockImplementation(async (key: string) => { if (key.includes(":previous_result")) { return { ok: true, data: mockPreviousResult }; } @@ -227,7 +225,7 @@ describe("License Core Logic", () => { const license = await getEnterpriseLicense(); - expect(mockCache.withCache).toHaveBeenCalled(); + expect(mockCache.get).toHaveBeenCalled(); expect(license).toEqual({ active: true, features: mockPreviousResult.features, @@ -250,11 +248,8 @@ describe("License Core Logic", () => { version: 1, }; - // Mock cache.withCache to return null (simulating fetch failure) - mockCache.withCache.mockResolvedValue(null); - - // Mock cache.get to return previous result when requested - mockCache.get.mockImplementation(async (key) => { + // Cache miss -> fetch fails -> null; getPreviousResult returns old previous result + mockCache.get.mockImplementation(async (key: string) => { if (key.includes(":previous_result")) { return { ok: true, data: mockPreviousResult }; } @@ -265,7 +260,7 @@ describe("License Core Logic", () => { const license = await getEnterpriseLicense(); - expect(mockCache.withCache).toHaveBeenCalled(); + expect(mockCache.get).toHaveBeenCalled(); expect(mockCache.set).toHaveBeenCalledWith( expect.stringContaining("fb:license:"), { @@ -319,12 +314,7 @@ describe("License Core Logic", () => { const { getEnterpriseLicense } = await import("./license"); const fetch = (await import("node-fetch")).default as Mock; - // Mock cache.withCache to return null (simulating fetch failure) - mockCache.withCache.mockResolvedValue(null); - - // Mock cache.get to return no previous result - mockCache.get.mockResolvedValue({ ok: true, data: null }); - + // Cache miss -> fetch fails; no previous result (default get returns null) fetch.mockRejectedValueOnce(new Error("Network error")); const license = await getEnterpriseLicense(); @@ -397,49 +387,83 @@ describe("License Core Logic", () => { }); expect(mockCache.get).not.toHaveBeenCalled(); expect(mockCache.set).not.toHaveBeenCalled(); - expect(mockCache.withCache).not.toHaveBeenCalled(); + expect(mockCache.exists).not.toHaveBeenCalled(); }); test("should handle fetch throwing an error and use grace period or return inactive", async () => { - const { getEnterpriseLicense } = await import("./license"); - const fetch = (await import("node-fetch")).default as Mock; - - // Mock cache.withCache to return null (simulating fetch failure) - mockCache.withCache.mockResolvedValue(null); - - // Mock cache.get to return no previous result - mockCache.get.mockResolvedValue({ ok: true, data: null }); - - fetch.mockRejectedValueOnce(new Error("Network error")); - - const license = await getEnterpriseLicense(); - expect(license).toEqual({ - active: false, - features: null, - lastChecked: expect.any(Date), - isPendingDowngrade: false, - fallbackLevel: "default" as const, - status: "no-license" as const, - }); - }); - }); - - describe("getLicenseFeatures", () => { - test("should return features if license is active", async () => { - // Set up environment before import - vi.stubGlobal("window", undefined); + // Runs after "no-license" test which uses vi.doMock; env may have empty key + vi.resetModules(); vi.doMock("@/lib/env", () => ({ env: { ENTERPRISE_LICENSE_KEY: "test-license-key", + ENVIRONMENT: "production", VERCEL_URL: "some.vercel.url", FORMBRICKS_COM_URL: "https://app.formbricks.com", HTTPS_PROXY: undefined, HTTP_PROXY: undefined, }, })); - // Mock cache.withCache to return license details - mockCache.withCache.mockResolvedValue({ - status: "active", + + const { getEnterpriseLicense } = await import("./license"); + const fetch = (await import("node-fetch")).default as Mock; + + // Cache miss -> fetch throws -> no previous result -> handleInitialFailure + fetch.mockRejectedValueOnce(new Error("Network error")); + + const license = await getEnterpriseLicense(); + expect(license).toEqual({ + active: false, + features: expect.objectContaining({ + isMultiOrgEnabled: false, + projects: 3, + removeBranding: false, + }), + lastChecked: expect.any(Date), + isPendingDowngrade: false, + fallbackLevel: "default" as const, + status: "unreachable" as const, + }); + }); + + test("should return invalid_license when API returns 400 (bad license key)", async () => { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ + env: { + ENTERPRISE_LICENSE_KEY: "test-license-key", + ENVIRONMENT: "production", + VERCEL_URL: "some.vercel.url", + FORMBRICKS_COM_URL: "https://app.formbricks.com", + HTTPS_PROXY: undefined, + HTTP_PROXY: undefined, + }, + })); + + const { getEnterpriseLicense } = await import("./license"); + const fetch = (await import("node-fetch")).default as Mock; + + mockCache.get.mockResolvedValue({ ok: true, data: null }); + fetch.mockResolvedValueOnce({ ok: false, status: 400 } as any); + + const license = await getEnterpriseLicense(); + + expect(license).toEqual({ + active: false, + features: expect.objectContaining({ projects: 3 }), + lastChecked: expect.any(Date), + isPendingDowngrade: false, + fallbackLevel: "default" as const, + status: "invalid_license" as const, + }); + }); + }); + + describe("getLicenseFeatures", () => { + test("should return features if license is active", async () => { + vi.resetModules(); + vi.stubGlobal("window", undefined); + // Mock cache hit for fetchLicense (get returns wrapped license) + const activeLicenseDetails = { + status: "active" as const, features: { isMultiOrgEnabled: true, contacts: true, @@ -452,9 +476,21 @@ describe("License Core Logic", () => { spamProtection: true, ai: true, auditLogs: true, + multiLanguageSurveys: true, + accessControl: true, + quotas: true, }, + }; + mockCache.get.mockImplementation(async (key: string) => { + if (key.includes(":previous_result")) { + return { ok: true, data: null }; + } + if (key.includes(":status")) { + return { ok: true, data: { value: activeLicenseDetails } }; + } + return { ok: true, data: null }; }); - // Import after env and mocks are set + const { getLicenseFeatures } = await import("./license"); const features = await getLicenseFeatures(); expect(features).toEqual({ @@ -469,14 +505,48 @@ describe("License Core Logic", () => { spamProtection: true, ai: true, auditLogs: true, + multiLanguageSurveys: true, + accessControl: true, + quotas: true, }); }); test("should return null if license is inactive", async () => { const { getLicenseFeatures } = await import("./license"); - // Mock cache.withCache to return expired license - mockCache.withCache.mockResolvedValue({ status: "expired", features: null }); + // Mock cache hit with expired license wrapped in { value: ... } + mockCache.get.mockImplementation(async (key: string) => { + if (key.includes(":previous_result")) { + return { ok: true, data: null }; + } + if (key.includes(":status")) { + return { + ok: true, + data: { + value: { + status: "expired", + features: { + isMultiOrgEnabled: false, + projects: 3, + twoFactorAuth: false, + sso: false, + whitelabel: false, + removeBranding: false, + contacts: false, + ai: false, + saml: false, + spamProtection: false, + auditLogs: false, + multiLanguageSurveys: false, + accessControl: false, + quotas: false, + }, + }, + }, + }; + } + return { ok: true, data: null }; + }); const features = await getLicenseFeatures(); expect(features).toBeNull(); @@ -485,8 +555,8 @@ describe("License Core Logic", () => { test("should return null if getEnterpriseLicense throws", async () => { const { getLicenseFeatures } = await import("./license"); - // Mock cache.withCache to throw an error - mockCache.withCache.mockRejectedValue(new Error("Cache error")); + // Mock cache.get to throw so getEnterpriseLicense fails + mockCache.get.mockRejectedValue(new Error("Cache error")); const features = await getLicenseFeatures(); expect(features).toBeNull(); @@ -499,23 +569,57 @@ describe("License Core Logic", () => { mockCache.get.mockReset(); mockCache.set.mockReset(); mockCache.del.mockReset(); - mockCache.withCache.mockReset(); + mockCache.exists.mockReset(); vi.resetModules(); }); test("should use 'browser' as cache key in browser environment", async () => { vi.stubGlobal("window", {}); - // Set up default mock for cache.withCache - mockCache.withCache.mockImplementation(async (fn) => await fn()); + // Ensure env has license key (previous "no-license" test may have poisoned env) + vi.doMock("@/lib/env", () => ({ + env: { + ENTERPRISE_LICENSE_KEY: "test-license-key", + ENVIRONMENT: "production", + VERCEL_URL: "some.vercel.url", + FORMBRICKS_COM_URL: "https://app.formbricks.com", + HTTPS_PROXY: undefined, + HTTP_PROXY: undefined, + }, + })); + + // Cache miss so fetch runs; mock get for cache check + mockCache.get.mockResolvedValue({ ok: true, data: null }); + + const fetch = (await import("node-fetch")).default as Mock; + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + status: "active", + features: { + isMultiOrgEnabled: true, + projects: 5, + twoFactorAuth: true, + sso: true, + whitelabel: true, + removeBranding: true, + contacts: true, + ai: true, + saml: true, + spamProtection: true, + auditLogs: true, + multiLanguageSurveys: true, + accessControl: true, + quotas: true, + }, + }, + }), + } as any); const { getEnterpriseLicense } = await import("./license"); await getEnterpriseLicense(); - expect(mockCache.withCache).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining("fb:license:browser:status"), - expect.any(Number) - ); + expect(mockCache.get).toHaveBeenCalledWith(expect.stringContaining("fb:license:browser:status")); }); test("should use 'no-license' as cache key when ENTERPRISE_LICENSE_KEY is not set", async () => { @@ -534,16 +638,19 @@ describe("License Core Logic", () => { await getEnterpriseLicense(); // The cache should NOT be accessed if there is no license key expect(mockCache.get).not.toHaveBeenCalled(); - expect(mockCache.withCache).not.toHaveBeenCalled(); + expect(mockCache.exists).not.toHaveBeenCalled(); }); test("should use hashed license key as cache key when ENTERPRISE_LICENSE_KEY is set", async () => { vi.resetModules(); const testLicenseKey = "test-license-key"; vi.stubGlobal("window", undefined); + + // Ensure env has license key (restore after "no-license" test) vi.doMock("@/lib/env", () => ({ env: { ENTERPRISE_LICENSE_KEY: testLicenseKey, + ENVIRONMENT: "production", VERCEL_URL: "some.vercel.url", FORMBRICKS_COM_URL: "https://app.formbricks.com", HTTPS_PROXY: undefined, @@ -551,22 +658,49 @@ describe("License Core Logic", () => { }, })); - // Set up default mock for cache.withCache - mockCache.withCache.mockImplementation(async (fn) => await fn()); + mockCache.get.mockResolvedValue({ ok: true, data: null }); + + const fetch = (await import("node-fetch")).default as Mock; + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + status: "active", + features: { + isMultiOrgEnabled: true, + projects: 5, + twoFactorAuth: true, + sso: true, + whitelabel: true, + removeBranding: true, + contacts: true, + ai: true, + saml: true, + spamProtection: true, + auditLogs: true, + multiLanguageSurveys: true, + accessControl: true, + quotas: true, + }, + }, + }), + } as any); const { hashString } = await import("@/lib/hash-string"); const expectedHash = hashString(testLicenseKey); const { getEnterpriseLicense } = await import("./license"); await getEnterpriseLicense(); - expect(mockCache.withCache).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining(`fb:license:${expectedHash}:status`), - expect.any(Number) + expect(mockCache.get).toHaveBeenCalledWith( + expect.stringContaining(`fb:license:${expectedHash}:status`) ); }); }); describe("Error and Warning Logging", () => { + beforeEach(() => { + vi.resetModules(); + }); + test("should log warning when setPreviousResult cache.set fails (line 176-178)", async () => { const { getEnterpriseLicense } = await import("./license"); const fetch = (await import("node-fetch")).default as Mock; @@ -591,10 +725,18 @@ describe("License Core Logic", () => { }, }; - // Mock successful fetch from API - mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails); + // Cache hit - fetchLicense returns wrapped cached license + mockCache.get.mockImplementation(async (key: string) => { + if (key.includes(":previous_result")) { + return { ok: true, data: null }; + } + if (key.includes(":status")) { + return { ok: true, data: { value: mockFetchedLicenseDetails } }; + } + return { ok: true, data: null }; + }); - // Mock cache.set to fail when saving previous result + // cache.set fails when setPreviousResult tries to save (called for previous_result key) mockCache.set.mockResolvedValue({ ok: false, error: new Error("Redis connection failed"), @@ -602,7 +744,6 @@ describe("License Core Logic", () => { await getEnterpriseLicense(); - // Verify that the warning was logged expect(mockLogger.warn).toHaveBeenCalledWith( { error: new Error("Redis connection failed") }, "Failed to cache previous result" @@ -613,10 +754,7 @@ describe("License Core Logic", () => { const { getEnterpriseLicense } = await import("./license"); const fetch = (await import("node-fetch")).default as Mock; - // Mock cache.withCache to execute the function (simulating cache miss) - mockCache.withCache.mockImplementation(async (fn) => await fn()); - - // Mock API response with 500 status + // Cache miss -> fetch returns 500 const mockStatus = 500; fetch.mockResolvedValueOnce({ ok: false, @@ -626,7 +764,6 @@ describe("License Core Logic", () => { await getEnterpriseLicense(); - // Verify that the API error was logged with correct structure expect(mockLogger.error).toHaveBeenCalledWith( expect.objectContaining({ status: mockStatus, @@ -641,8 +778,7 @@ describe("License Core Logic", () => { const { getEnterpriseLicense } = await import("./license"); const fetch = (await import("node-fetch")).default as Mock; - // Test with 403 Forbidden - mockCache.withCache.mockImplementation(async (fn) => await fn()); + // Cache miss -> fetch returns 403 const mockStatus = 403; fetch.mockResolvedValueOnce({ ok: false, @@ -652,7 +788,6 @@ describe("License Core Logic", () => { await getEnterpriseLicense(); - // Verify that the API error was logged with correct structure expect(mockLogger.error).toHaveBeenCalledWith( expect.objectContaining({ status: mockStatus, @@ -675,8 +810,7 @@ describe("License Core Logic", () => { version: 1, }; - mockCache.withCache.mockResolvedValue(null); - mockCache.get.mockImplementation(async (key) => { + mockCache.get.mockImplementation(async (key: string) => { if (key.includes(":previous_result")) { return { ok: true, data: mockPreviousResult }; } @@ -687,7 +821,6 @@ describe("License Core Logic", () => { await getEnterpriseLicense(); - // Verify that the fallback info was logged expect(mockLogger.info).toHaveBeenCalledWith( expect.objectContaining({ fallbackLevel: "grace", @@ -698,6 +831,220 @@ describe("License Core Logic", () => { }); }); + describe("computeFreshLicenseState", () => { + const mockActiveLicenseDetails: TEnterpriseLicenseDetails = { + status: "active", + features: { + isMultiOrgEnabled: true, + contacts: true, + projects: 10, + whitelabel: true, + removeBranding: true, + twoFactorAuth: true, + sso: true, + saml: true, + spamProtection: true, + ai: false, + auditLogs: true, + multiLanguageSurveys: true, + accessControl: true, + quotas: true, + }, + }; + + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + mockCache.get.mockResolvedValue({ ok: true, data: null }); + mockCache.exists.mockResolvedValue({ ok: true, data: false }); + mockCache.set.mockResolvedValue({ ok: true }); + }); + + test("should return active license state from pre-fetched active license without calling fetch", async () => { + const { computeFreshLicenseState } = await import("./license"); + const fetch = (await import("node-fetch")).default as Mock; + + const result = await computeFreshLicenseState(mockActiveLicenseDetails); + + expect(result).toEqual({ + active: true, + features: mockActiveLicenseDetails.features, + lastChecked: expect.any(Date), + isPendingDowngrade: false, + fallbackLevel: "live", + status: "active", + }); + // Must not call the license API — the data was passed in directly + expect(fetch).not.toHaveBeenCalled(); + }); + + test("should apply grace period fallback when freshLicense is null and previous result exists within grace", async () => { + const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago + const mockPreviousResult = { + active: true, + features: { removeBranding: true, projects: 5 }, + lastChecked: previousTime, + }; + + mockCache.get.mockImplementation(async (key: string) => { + if (key.includes(":previous_result")) { + return { ok: true, data: mockPreviousResult }; + } + return { ok: true, data: null }; + }); + + const { computeFreshLicenseState } = await import("./license"); + const fetch = (await import("node-fetch")).default as Mock; + + const result = await computeFreshLicenseState(null); + + expect(result).toEqual({ + active: true, + features: mockPreviousResult.features, + lastChecked: previousTime, + isPendingDowngrade: true, + fallbackLevel: "grace", + status: "unreachable", + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("should return inactive default when freshLicense is null and no previous result", async () => { + const { computeFreshLicenseState } = await import("./license"); + const fetch = (await import("node-fetch")).default as Mock; + + const result = await computeFreshLicenseState(null); + + expect(result).toEqual({ + active: false, + features: expect.objectContaining({ + isMultiOrgEnabled: false, + projects: 3, + }), + lastChecked: expect.any(Date), + isPendingDowngrade: false, + fallbackLevel: "default", + status: "unreachable", + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("should return expired state when freshLicense has expired status", async () => { + const expiredLicense: TEnterpriseLicenseDetails = { + status: "expired", + features: mockActiveLicenseDetails.features, + }; + + const { computeFreshLicenseState } = await import("./license"); + + const result = await computeFreshLicenseState(expiredLicense); + + expect(result).toEqual({ + active: false, + features: expect.objectContaining({ + isMultiOrgEnabled: false, + projects: 3, + }), + lastChecked: expect.any(Date), + isPendingDowngrade: false, + fallbackLevel: "default", + status: "expired", + }); + }); + }); + + describe("clearLicenseCache", () => { + test("should clear memory cache and delete FETCH_LICENSE_CACHE_KEY", async () => { + const { clearLicenseCache, getEnterpriseLicense } = await import("./license"); + const activeLicense = { + status: "active" as const, + features: { + isMultiOrgEnabled: true, + projects: 5, + twoFactorAuth: true, + sso: true, + whitelabel: true, + removeBranding: true, + contacts: true, + ai: true, + saml: true, + spamProtection: true, + auditLogs: true, + multiLanguageSurveys: true, + accessControl: true, + quotas: true, + }, + }; + mockCache.get.mockImplementation(async (key: string) => { + if (key.includes(":previous_result")) return { ok: true, data: null }; + if (key.includes(":status")) return { ok: true, data: { value: activeLicense } }; + return { ok: true, data: null }; + }); + mockCache.del.mockResolvedValue({ ok: true }); + + await getEnterpriseLicense(); + await clearLicenseCache(); + + expect(mockCache.del).toHaveBeenCalledWith(expect.arrayContaining([expect.stringContaining("fb:license:")])); + }); + + test("should log warning when cache.del fails", async () => { + const { clearLicenseCache } = await import("./license"); + mockCache.del.mockResolvedValue({ ok: false, error: new Error("Redis error") }); + + await clearLicenseCache(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + { error: new Error("Redis error") }, + "Failed to delete license cache" + ); + }); + }); + + describe("fetchLicenseFresh", () => { + test("should fetch directly from server without using cache", async () => { + const { fetchLicenseFresh } = await import("./license"); + const fetch = (await import("node-fetch")).default as Mock; + + mockCache.get.mockResolvedValue({ ok: true, data: null }); + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + status: "active", + features: { + isMultiOrgEnabled: true, + projects: 5, + twoFactorAuth: true, + sso: true, + whitelabel: true, + removeBranding: true, + contacts: true, + ai: true, + saml: true, + spamProtection: true, + auditLogs: true, + multiLanguageSurveys: true, + accessControl: true, + quotas: true, + }, + }, + }), + } as any); + + const result = await fetchLicenseFresh(); + + expect(result).toEqual( + expect.objectContaining({ + status: "active", + features: expect.objectContaining({ projects: 5 }), + }) + ); + expect(fetch).toHaveBeenCalled(); + expect(mockCache.get).not.toHaveBeenCalled(); + }); + }); + describe("Environment-based endpoint selection", () => { test("should use staging endpoint when ENVIRONMENT is staging", async () => { vi.resetModules(); @@ -712,8 +1059,9 @@ describe("License Core Logic", () => { const fetch = (await import("node-fetch")).default as Mock; - // Mock cache.withCache to execute the function (simulating cache miss) - mockCache.withCache.mockImplementation(async (fn) => await fn()); + // Cache miss so fetchLicense fetches from server + mockCache.get.mockResolvedValue({ ok: true, data: null }); + mockCache.exists.mockResolvedValue({ ok: true, data: false }); fetch.mockResolvedValueOnce({ ok: true, diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts index 34f7893562..a031923600 100644 --- a/apps/web/modules/ee/license-check/lib/license.ts +++ b/apps/web/modules/ee/license-check/lib/license.ts @@ -14,12 +14,14 @@ import { getInstanceId } from "@/lib/instance"; import { TEnterpriseLicenseDetails, TEnterpriseLicenseFeatures, + TEnterpriseLicenseStatusReturn, } from "@/modules/ee/license-check/types/enterprise-license"; // Configuration const CONFIG = { CACHE: { FETCH_LICENSE_TTL_MS: 24 * 60 * 60 * 1000, // 24 hours + FAILED_FETCH_TTL_MS: 10 * 60 * 1000, // 10 minutes for failed/null results PREVIOUS_RESULT_TTL_MS: 4 * 24 * 60 * 60 * 1000, // 4 days GRACE_PERIOD_MS: 3 * 24 * 60 * 60 * 1000, // 3 days MAX_RETRIES: 3, @@ -30,16 +32,20 @@ const CONFIG = { env.ENVIRONMENT === "staging" ? "https://staging.ee.formbricks.com/api/licenses/check" : "https://ee.formbricks.com/api/licenses/check", - // ENDPOINT: "https://localhost:8080/api/licenses/check", TIMEOUT_MS: 5000, }, } as const; +export const GRACE_PERIOD_MS = CONFIG.CACHE.GRACE_PERIOD_MS; + +/** TTL in ms for successful license fetch results (24h). Re-export for use in actions. */ +export const FETCH_LICENSE_TTL_MS = CONFIG.CACHE.FETCH_LICENSE_TTL_MS; +/** TTL in ms for failed license fetch results (10 min). Re-export for use in actions. */ +export const FAILED_FETCH_TTL_MS = CONFIG.CACHE.FAILED_FETCH_TTL_MS; + // Types type FallbackLevel = "live" | "cached" | "grace" | "default"; -type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "no-license"; - type TEnterpriseLicenseResult = { active: boolean; features: TEnterpriseLicenseFeatures | null; @@ -55,6 +61,13 @@ type TPreviousResult = { features: TEnterpriseLicenseFeatures | null; }; +// Wrapper type for cached license fetch results. +// Storing { value: } instead of directly lets us distinguish +// "key not in cache" (get returns null) from "key exists with a null value" +// ({ value: null }) in a single cache.get call, eliminating the TOCTOU race +// that existed between separate get + exists calls. +type TCachedFetchResult = { value: TEnterpriseLicenseDetails | null }; + // Validation schemas const LicenseFeaturesSchema = z.object({ isMultiOrgEnabled: z.boolean(), @@ -89,7 +102,7 @@ class LicenseError extends Error { } } -class LicenseApiError extends LicenseError { +export class LicenseApiError extends LicenseError { constructor( message: string, public readonly status: number @@ -378,12 +391,23 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise } fetchLicensePromise = (async () => { - return await cache.withCache( - async () => { - return await fetchLicenseFromServerInternal(); - }, - getCacheKeys().FETCH_LICENSE_CACHE_KEY, - CONFIG.CACHE.FETCH_LICENSE_TTL_MS - ); + // Check cache first — a single get call distinguishes "not cached" + // (data is null) from "cached null" (data is { value: null }). + const cacheKey = getCacheKeys().FETCH_LICENSE_CACHE_KEY; + const cached = await cache.get(cacheKey); + + if (cached.ok && cached.data !== null && "value" in cached.data) { + return cached.data.value; + } + + // Cache miss -- fetch fresh + const result = await fetchLicenseFromServerInternal(); + const ttl = result ? CONFIG.CACHE.FETCH_LICENSE_TTL_MS : CONFIG.CACHE.FAILED_FETCH_TTL_MS; + + if (!result) { + logger.warn( + { + ttlMinutes: Math.floor(ttl / 60000), + timestamp: new Date().toISOString(), + }, + "License fetch failed, caching null result with short TTL for faster retry" + ); + } + + await cache.set(getCacheKeys().FETCH_LICENSE_CACHE_KEY, { value: result }, ttl); + return result; })(); fetchLicensePromise @@ -420,6 +462,115 @@ export const fetchLicense = async (): Promise return fetchLicensePromise; }; +/** + * Core license state evaluation logic. + * Accepts pre-fetched license details and applies fallback / grace-period rules. + * Sets the in-process memoryCache as a side effect so subsequent requests benefit. + */ +const computeLicenseState = async ( + liveLicenseDetails: TEnterpriseLicenseDetails | null +): Promise => { + validateConfig(); + + if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) { + return { + active: false, + features: null, + lastChecked: new Date(), + isPendingDowngrade: false, + fallbackLevel: "default" as const, + status: "no-license" as const, + }; + } + + const currentTime = new Date(); + const previousResult = await getPreviousResult(); + const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime); + trackFallbackUsage(fallbackLevel); + + let currentLicenseState: TPreviousResult | undefined; + + switch (fallbackLevel) { + case "live": { + if (!liveLicenseDetails) throw new Error("Invalid state: live license expected"); + currentLicenseState = { + active: liveLicenseDetails.status === "active", + features: liveLicenseDetails.features, + lastChecked: currentTime, + }; + + // Only update previous result if it's actually different or if it's old (1 hour) + // This prevents hammering Redis on every request when the license is active + if ( + !previousResult.active || + previousResult.active !== currentLicenseState.active || + currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000 + ) { + await setPreviousResult(currentLicenseState); + } + + const liveResult: TEnterpriseLicenseResult = { + active: currentLicenseState.active, + features: currentLicenseState.features, + lastChecked: currentTime, + isPendingDowngrade: false, + fallbackLevel: "live" as const, + status: liveLicenseDetails.status, + }; + memoryCache = { data: liveResult, timestamp: Date.now() }; + return liveResult; + } + + case "grace": { + if (!validateFallback(previousResult)) { + return await handleInitialFailure(currentTime); + } + logger.warn( + { + lastChecked: previousResult.lastChecked.toISOString(), + gracePeriodEnds: new Date( + previousResult.lastChecked.getTime() + CONFIG.CACHE.GRACE_PERIOD_MS + ).toISOString(), + timestamp: new Date().toISOString(), + }, + "License server unreachable, using grace period. Will retry in ~10 minutes." + ); + const graceResult: TEnterpriseLicenseResult = { + active: previousResult.active, + features: previousResult.features, + lastChecked: previousResult.lastChecked, + isPendingDowngrade: true, + fallbackLevel: "grace" as const, + status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable", + }; + memoryCache = { data: graceResult, timestamp: Date.now() }; + return graceResult; + } + + case "default": { + if (liveLicenseDetails?.status === "expired") { + const expiredResult: TEnterpriseLicenseResult = { + active: false, + features: DEFAULT_FEATURES, + lastChecked: currentTime, + isPendingDowngrade: false, + fallbackLevel: "default" as const, + status: "expired" as const, + }; + memoryCache = { data: expiredResult, timestamp: Date.now() }; + return expiredResult; + } + const failResult = await handleInitialFailure(currentTime); + memoryCache = { data: failResult, timestamp: Date.now() }; + return failResult; + } + } + + const finalFailResult = await handleInitialFailure(currentTime); + memoryCache = { data: finalFailResult, timestamp: Date.now() }; + return finalFailResult; +}; + export const getEnterpriseLicense = reactCache(async (): Promise => { if ( process.env.NODE_ENV !== "test" && @@ -432,95 +583,27 @@ export const getEnterpriseLicense = reactCache(async (): Promise { - validateConfig(); + let liveLicenseDetails: TEnterpriseLicenseDetails | null = null; - if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) { - return { - active: false, - features: null, - lastChecked: new Date(), - isPendingDowngrade: false, - fallbackLevel: "default" as const, - status: "no-license" as const, - }; - } - const currentTime = new Date(); - const [liveLicenseDetails, previousResult] = await Promise.all([fetchLicense(), getPreviousResult()]); - const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime); - - trackFallbackUsage(fallbackLevel); - - let currentLicenseState: TPreviousResult | undefined; - - switch (fallbackLevel) { - case "live": { - if (!liveLicenseDetails) throw new Error("Invalid state: live license expected"); - currentLicenseState = { - active: liveLicenseDetails.status === "active", - features: liveLicenseDetails.features, - lastChecked: currentTime, - }; - - // Only update previous result if it's actually different or if it's old (1 hour) - // This prevents hammering Redis on every request when the license is active - if ( - !previousResult.active || - previousResult.active !== currentLicenseState.active || - currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000 - ) { - await setPreviousResult(currentLicenseState); - } - - const liveResult: TEnterpriseLicenseResult = { - active: currentLicenseState.active, - features: currentLicenseState.features, - lastChecked: currentTime, + try { + liveLicenseDetails = await fetchLicense(); + } catch (error) { + if (error instanceof LicenseApiError && error.status === 400) { + const invalidResult: TEnterpriseLicenseResult = { + active: false, + features: DEFAULT_FEATURES, + lastChecked: new Date(), isPendingDowngrade: false, - fallbackLevel: "live" as const, - status: liveLicenseDetails.status, + fallbackLevel: "default" as const, + status: "invalid_license" as const, }; - memoryCache = { data: liveResult, timestamp: Date.now() }; - return liveResult; - } - - case "grace": { - if (!validateFallback(previousResult)) { - return await handleInitialFailure(currentTime); - } - const graceResult: TEnterpriseLicenseResult = { - active: previousResult.active, - features: previousResult.features, - lastChecked: previousResult.lastChecked, - isPendingDowngrade: true, - fallbackLevel: "grace" as const, - status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable", - }; - memoryCache = { data: graceResult, timestamp: Date.now() }; - return graceResult; - } - - case "default": { - if (liveLicenseDetails?.status === "expired") { - const expiredResult: TEnterpriseLicenseResult = { - active: false, - features: DEFAULT_FEATURES, - lastChecked: currentTime, - isPendingDowngrade: false, - fallbackLevel: "default" as const, - status: "expired" as const, - }; - memoryCache = { data: expiredResult, timestamp: Date.now() }; - return expiredResult; - } - const failResult = await handleInitialFailure(currentTime); - memoryCache = { data: failResult, timestamp: Date.now() }; - return failResult; + memoryCache = { data: invalidResult, timestamp: Date.now() }; + return invalidResult; } + // Other errors: liveLicenseDetails stays null (treated as unreachable) } - const finalFailResult = await handleInitialFailure(currentTime); - memoryCache = { data: finalFailResult, timestamp: Date.now() }; - return finalFailResult; + return computeLicenseState(liveLicenseDetails); })(); getEnterpriseLicensePromise @@ -542,4 +625,55 @@ export const getLicenseFeatures = async (): Promise => { + memoryCache = null; + const cacheKeys = getCacheKeys(); + // Only clear the main fetch cache, NOT the previous result cache + // This preserves the grace period fallback if the server is unreachable + const delResult = await cache.del([cacheKeys.FETCH_LICENSE_CACHE_KEY]); + if (!delResult.ok) { + logger.warn({ error: delResult.error }, "Failed to delete license cache"); + } +}; + +/** + * Fetch license directly from server without using cache. + * Used by the recheck license action for a fresh check. + * Concurrent callers share a single in-flight request to avoid + * hammering the license server (e.g. multiple managers rechecking). + */ +let fetchLicenseFreshPromise: Promise | null = null; + +export const fetchLicenseFresh = async (): Promise => { + if (fetchLicenseFreshPromise) return fetchLicenseFreshPromise; + + fetchLicenseFreshPromise = fetchLicenseFromServerInternal(); + + fetchLicenseFreshPromise + .finally(() => { + fetchLicenseFreshPromise = null; + }) + .catch(() => {}); + + return fetchLicenseFreshPromise; +}; + +/** + * Compute license state from pre-fetched license data, bypassing React cache + * and the in-process memory cache. Used by the recheck action to guarantee + * fresh evaluation after clearing caches and fetching new data. + * Refreshes the in-process memory cache as a side effect so subsequent + * requests benefit from the fresh result. + */ +export const computeFreshLicenseState = async ( + freshLicense: TEnterpriseLicenseDetails | null +): Promise => { + memoryCache = null; + return computeLicenseState(freshLicense); +}; + // All permission checking functions and their helpers have been moved to utils.ts diff --git a/apps/web/modules/ee/license-check/types/enterprise-license.ts b/apps/web/modules/ee/license-check/types/enterprise-license.ts index 8144692e22..410736aea6 100644 --- a/apps/web/modules/ee/license-check/types/enterprise-license.ts +++ b/apps/web/modules/ee/license-check/types/enterprise-license.ts @@ -29,3 +29,5 @@ export const ZEnterpriseLicenseDetails = z.object({ }); export type TEnterpriseLicenseDetails = z.infer; + +export type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "invalid_license" | "no-license"; diff --git a/apps/web/modules/environments/types/environment-auth.ts b/apps/web/modules/environments/types/environment-auth.ts index f0354d2c59..4b2891c5b3 100644 --- a/apps/web/modules/environments/types/environment-auth.ts +++ b/apps/web/modules/environments/types/environment-auth.ts @@ -15,7 +15,7 @@ type TEnterpriseLicense = { lastChecked: Date; isPendingDowngrade: boolean; fallbackLevel: string; - status: "active" | "expired" | "unreachable" | "no-license"; + status: "active" | "expired" | "unreachable" | "no-license" | "invalid_license"; }; export const ZEnvironmentAuth = z.object({ diff --git a/apps/web/modules/ui/components/pending-downgrade-banner/index.tsx b/apps/web/modules/ui/components/pending-downgrade-banner/index.tsx index bf8c50b3f6..6aa49239d1 100644 --- a/apps/web/modules/ui/components/pending-downgrade-banner/index.tsx +++ b/apps/web/modules/ui/components/pending-downgrade-banner/index.tsx @@ -12,7 +12,7 @@ interface PendingDowngradeBannerProps { isPendingDowngrade: boolean; environmentId: string; locale: TUserLocale; - status: "active" | "expired" | "unreachable" | "no-license"; + status: "active" | "expired" | "unreachable" | "no-license" | "invalid_license"; } export const PendingDowngradeBanner = ({