Compare commits

..

7 Commits

Author SHA1 Message Date
Bhagya Amarasinghe
972407eb93 fix: optimize license check flow (backport to 4.6) (#7193) 2026-02-02 19:21:11 +05:30
Dhruwang Jariwala
fcbb99c43d fix: (backport) jerky animation behaviour (#7158) (#7163) 2026-01-26 10:24:19 +05:30
Dhruwang Jariwala
ec415a7aa1 fix: (backport) nps & rating rtl UI (#7154) (#7162) 2026-01-23 18:29:09 +05:30
Anshuman Pandey
a1e53c9051 fix: [Backport] fixes response card UI for cta question (#7161) 2026-01-23 17:58:53 +05:30
Anshuman Pandey
680295c63e fix: [Backport] fixes the cta element survey not found error (#7160) 2026-01-23 17:58:31 +05:30
Anshuman Pandey
73b40469f7 fix: (BACKPORT) language variants not working for app surveys (#7151) (#7159) 2026-01-23 17:32:00 +05:30
Dhruwang Jariwala
282e061606 fix: language variants not working for app surveys (#7151) 2026-01-23 17:10:33 +05:30
45 changed files with 520 additions and 486 deletions

View File

@@ -23,7 +23,7 @@
"@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"esbuild": "0.25.12",
"esbuild": "0.27.2",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.1.11",
"prop-types": "15.8.1",

View File

@@ -36,7 +36,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Calculate derived values (no queries)
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
const { features, lastChecked, isPendingDowngrade, active } = license;
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager;
@@ -63,6 +63,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
active={active}
environmentId={environment.id}
locale={user.locale}
status={status}
/>
<div className="flex h-full">

View File

@@ -254,6 +254,7 @@
"label": "Bezeichnung",
"language": "Sprache",
"learn_more": "Mehr erfahren",
"license_expired": "License Expired",
"light_overlay": "Helle Überlagerung",
"limits_reached": "Limits erreicht",
"link": "Link",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
"you_have_reached_your_monthly_miu_limit_of": "Du hast dein monatliches MIU-Limit erreicht",
"you_have_reached_your_monthly_response_limit_of": "Du hast dein monatliches Antwortlimit erreicht",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft."
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Annehmen",

View File

@@ -254,6 +254,7 @@
"label": "Label",
"language": "Language",
"learn_more": "Learn more",
"license_expired": "License Expired",
"light_overlay": "Light overlay",
"limits_reached": "Limits Reached",
"link": "Link",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {projectLimit} workspaces.",
"you_have_reached_your_monthly_miu_limit_of": "You have reached your monthly MIU limit of",
"you_have_reached_your_monthly_response_limit_of": "You have reached your monthly response limit of",
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Accept",

View File

@@ -254,6 +254,7 @@
"label": "Etiqueta",
"language": "Idioma",
"learn_more": "Saber más",
"license_expired": "License Expired",
"light_overlay": "Superposición clara",
"limits_reached": "Límites alcanzados",
"link": "Enlace",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {projectLimit} espacios de trabajo.",
"you_have_reached_your_monthly_miu_limit_of": "Has alcanzado tu límite mensual de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Has alcanzado tu límite mensual de respuestas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Aceptar",

View File

@@ -254,6 +254,7 @@
"label": "Étiquette",
"language": "Langue",
"learn_more": "En savoir plus",
"license_expired": "License Expired",
"light_overlay": "Claire",
"limits_reached": "Limites atteints",
"link": "Lien",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {projectLimit} espaces de travail.",
"you_have_reached_your_monthly_miu_limit_of": "Vous avez atteint votre limite mensuelle de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Vous avez atteint votre limite de réponses mensuelle de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Accepter",

View File

@@ -254,6 +254,7 @@
"label": "ラベル",
"language": "言語",
"learn_more": "詳細を見る",
"license_expired": "License Expired",
"light_overlay": "明るいオーバーレイ",
"limits_reached": "上限に達しました",
"link": "リンク",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{projectLimit}件に達しました。",
"you_have_reached_your_monthly_miu_limit_of": "月間MIU月間アクティブユーザーの上限に達しました",
"you_have_reached_your_monthly_response_limit_of": "月間回答数の上限に達しました",
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。"
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "承認",

View File

@@ -254,6 +254,7 @@
"label": "Label",
"language": "Taal",
"learn_more": "Meer informatie",
"license_expired": "License Expired",
"light_overlay": "Lichte overlay",
"limits_reached": "Grenzen bereikt",
"link": "Link",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {projectLimit} werkruimtes bereikt.",
"you_have_reached_your_monthly_miu_limit_of": "U heeft uw maandelijkse MIU-limiet van bereikt",
"you_have_reached_your_monthly_response_limit_of": "U heeft uw maandelijkse responslimiet bereikt van",
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Accepteren",

View File

@@ -254,6 +254,7 @@
"label": "Etiqueta",
"language": "Língua",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "sobreposição leve",
"limits_reached": "Limites Atingidos",
"link": "link",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Você atingiu seu limite de {projectLimit} espaços de trabalho.",
"you_have_reached_your_monthly_miu_limit_of": "Você atingiu o seu limite mensal de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Você atingiu o limite mensal de respostas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Aceitar",

View File

@@ -254,6 +254,7 @@
"label": "Etiqueta",
"language": "Idioma",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "Sobreposição leve",
"limits_reached": "Limites Atingidos",
"link": "Link",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Atingiu o seu limite de {projectLimit} áreas de trabalho.",
"you_have_reached_your_monthly_miu_limit_of": "Atingiu o seu limite mensal de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Atingiu o seu limite mensal de respostas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Aceitar",

View File

@@ -254,6 +254,7 @@
"label": "Etichetă",
"language": "Limba",
"learn_more": "Află mai multe",
"license_expired": "License Expired",
"light_overlay": "Suprapunere ușoară",
"limits_reached": "Limite atinse",
"link": "Legătura",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Ați atins limita de {projectLimit} spații de lucru.",
"you_have_reached_your_monthly_miu_limit_of": "Ați atins limita lunară MIU de",
"you_have_reached_your_monthly_response_limit_of": "Ați atins limita lunară de răspunsuri de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Acceptă",

View File

@@ -254,6 +254,7 @@
"label": "Метка",
"language": "Язык",
"learn_more": "Подробнее",
"license_expired": "License Expired",
"light_overlay": "Светлый оверлей",
"limits_reached": "Достигнуты лимиты",
"link": "Ссылка",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {projectLimit} рабочих пространств.",
"you_have_reached_your_monthly_miu_limit_of": "Вы достигли месячного лимита MIU:",
"you_have_reached_your_monthly_response_limit_of": "Вы достигли месячного лимита ответов:",
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Принять",

View File

@@ -254,6 +254,7 @@
"label": "Etikett",
"language": "Språk",
"learn_more": "Läs mer",
"license_expired": "License Expired",
"light_overlay": "Ljust överlägg",
"limits_reached": "Gränser nådda",
"link": "Länk",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {projectLimit} arbetsytor.",
"you_have_reached_your_monthly_miu_limit_of": "Du har nått din månatliga MIU-gräns på",
"you_have_reached_your_monthly_response_limit_of": "Du har nått din månatliga svarsgräns på",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Acceptera",

View File

@@ -254,6 +254,7 @@
"label": "标签",
"language": "语言",
"learn_more": "了解 更多",
"license_expired": "License Expired",
"light_overlay": "浅色遮罩层",
"limits_reached": "限制 达到",
"link": "链接",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {projectLimit} 个工作区的上限。",
"you_have_reached_your_monthly_miu_limit_of": "您 已经 达到 每月 的 MIU 限制",
"you_have_reached_your_monthly_response_limit_of": "您 已经 达到 每月 的 响应 限制",
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。"
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "接受",

View File

@@ -254,6 +254,7 @@
"label": "標籤",
"language": "語言",
"learn_more": "瞭解更多",
"license_expired": "License Expired",
"light_overlay": "淺色覆蓋",
"limits_reached": "已達上限",
"link": "連結",
@@ -460,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {projectLimit} 個工作區的上限。",
"you_have_reached_your_monthly_miu_limit_of": "您已達到每月 MIU 上限:",
"you_have_reached_your_monthly_response_limit_of": "您已達到每月回應上限:",
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。"
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "接受",

View File

@@ -157,6 +157,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "live" as const,
status: "active" as const,
};
test("should return cached license from FETCH_LICENSE_CACHE_KEY if available and valid", async () => {
@@ -233,6 +234,7 @@ describe("License Core Logic", () => {
lastChecked: previousTime,
isPendingDowngrade: true,
fallbackLevel: "grace" as const,
status: "unreachable" as const,
});
});
@@ -309,6 +311,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "unreachable" as const,
});
});
@@ -356,6 +359,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "unreachable" as const,
});
});
@@ -389,6 +393,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "no-license" as const,
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.set).not.toHaveBeenCalled();
@@ -414,6 +419,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "no-license" as const,
});
});
});

View File

@@ -38,6 +38,17 @@ const CONFIG = {
// Types
type FallbackLevel = "live" | "cached" | "grace" | "default";
type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "no-license";
type TEnterpriseLicenseResult = {
active: boolean;
features: TEnterpriseLicenseFeatures | null;
lastChecked: Date;
isPendingDowngrade: boolean;
fallbackLevel: FallbackLevel;
status: TEnterpriseLicenseStatusReturn;
};
type TPreviousResult = {
active: boolean;
lastChecked: Date;
@@ -90,7 +101,7 @@ class LicenseApiError extends LicenseError {
// Cache keys using enterprise-grade hierarchical patterns
const getCacheIdentifier = () => {
if (typeof window !== "undefined") {
if (globalThis.window !== undefined) {
return "browser"; // Browser environment
}
if (!env.ENTERPRISE_LICENSE_KEY) {
@@ -142,36 +153,50 @@ const validateConfig = () => {
};
// Cache functions with async pattern
let getPreviousResultPromise: Promise<TPreviousResult> | null = null;
const getPreviousResult = async (): Promise<TPreviousResult> => {
if (typeof window !== "undefined") {
if (getPreviousResultPromise) return getPreviousResultPromise;
getPreviousResultPromise = (async () => {
if (globalThis.window !== undefined) {
return {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
};
}
try {
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
if (result.ok && result.data) {
return {
...result.data,
lastChecked: new Date(result.data.lastChecked),
};
}
} catch (error) {
logger.error({ error }, "Failed to get previous result from cache");
}
return {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
};
}
})();
try {
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
if (result.ok && result.data) {
return {
...result.data,
lastChecked: new Date(result.data.lastChecked),
};
}
} catch (error) {
logger.error({ error }, "Failed to get previous result from cache");
}
getPreviousResultPromise
.finally(() => {
getPreviousResultPromise = null;
})
.catch(() => {});
return {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
};
return getPreviousResultPromise;
};
const setPreviousResult = async (previousResult: TPreviousResult) => {
if (typeof window !== "undefined") return;
if (globalThis.window !== undefined) return;
try {
const result = await cache.set(
@@ -221,12 +246,21 @@ const validateLicenseDetails = (data: unknown): TEnterpriseLicenseDetails => {
};
// Fallback functions
let memoryCache: {
data: TEnterpriseLicenseResult;
timestamp: number;
} | null = null;
const MEMORY_CACHE_TTL_MS = 60 * 1000; // 1 minute memory cache to avoid stampedes and reduce load when Redis is slow
let getEnterpriseLicensePromise: Promise<TEnterpriseLicenseResult> | null = null;
const getFallbackLevel = (
liveLicense: TEnterpriseLicenseDetails | null,
previousResult: TPreviousResult,
currentTime: Date
): FallbackLevel => {
if (liveLicense) return "live";
if (liveLicense?.status === "active") return "live";
if (previousResult.active) {
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
return elapsedTime < CONFIG.CACHE.GRACE_PERIOD_MS ? "grace" : "default";
@@ -234,7 +268,7 @@ const getFallbackLevel = (
return "default";
};
const handleInitialFailure = async (currentTime: Date) => {
const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicenseResult> => {
const initialFailResult: TPreviousResult = {
active: false,
features: DEFAULT_FEATURES,
@@ -247,10 +281,13 @@ const handleInitialFailure = async (currentTime: Date) => {
lastChecked: currentTime,
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "unreachable" as const,
};
};
// API functions
let fetchLicensePromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
@@ -266,6 +303,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
// first millisecond of next year => current year is fully included
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
const startTime = Date.now();
const [instanceId, responseCount] = await Promise.all([
// Skip instance ID during E2E tests to avoid license key conflicts
// as the instance ID changes with each test run
@@ -279,6 +317,11 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
},
}),
]);
const duration = Date.now() - startTime;
if (duration > 1000) {
logger.warn({ duration, responseCount }, "Slow license check prerequisite data fetching (DB count)");
}
// No organization exists, cannot perform license check
// (skip this check during E2E tests as we intentionally use null)
@@ -311,7 +354,19 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
if (res.ok) {
const responseJson = (await res.json()) as { data: unknown };
return validateLicenseDetails(responseJson.data);
const licenseDetails = validateLicenseDetails(responseJson.data);
logger.debug(
{
status: licenseDetails.status,
instanceId: instanceId ?? "not-set",
responseCount,
timestamp: new Date().toISOString(),
},
"License check API response received"
);
return licenseDetails;
}
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
@@ -342,23 +397,41 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
return null;
}
return await cache.withCache(
async () => {
return await fetchLicenseFromServerInternal();
},
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
);
if (fetchLicensePromise) {
return fetchLicensePromise;
}
fetchLicensePromise = (async () => {
return await cache.withCache(
async () => {
return await fetchLicenseFromServerInternal();
},
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
);
})();
fetchLicensePromise
.finally(() => {
fetchLicensePromise = null;
})
.catch(() => {});
return fetchLicensePromise;
};
export const getEnterpriseLicense = reactCache(
async (): Promise<{
active: boolean;
features: TEnterpriseLicenseFeatures | null;
lastChecked: Date;
isPendingDowngrade: boolean;
fallbackLevel: FallbackLevel;
}> => {
export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLicenseResult> => {
if (
process.env.NODE_ENV !== "test" &&
memoryCache &&
Date.now() - memoryCache.timestamp < MEMORY_CACHE_TTL_MS
) {
return memoryCache.data;
}
if (getEnterpriseLicensePromise) return getEnterpriseLicensePromise;
getEnterpriseLicensePromise = (async () => {
validateConfig();
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
@@ -368,12 +441,11 @@ export const getEnterpriseLicense = reactCache(
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "no-license" as const,
};
}
const currentTime = new Date();
const liveLicenseDetails = await fetchLicense();
const previousResult = await getPreviousResult();
const [liveLicenseDetails, previousResult] = await Promise.all([fetchLicense(), getPreviousResult()]);
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
trackFallbackUsage(fallbackLevel);
@@ -381,41 +453,84 @@ export const getEnterpriseLicense = reactCache(
let currentLicenseState: TPreviousResult | undefined;
switch (fallbackLevel) {
case "live":
case "live": {
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
currentLicenseState = {
active: liveLicenseDetails.status === "active",
features: liveLicenseDetails.features,
lastChecked: currentTime,
};
await setPreviousResult(currentLicenseState);
return {
// 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":
case "grace": {
if (!validateFallback(previousResult)) {
return handleInitialFailure(currentTime);
return await handleInitialFailure(currentTime);
}
return {
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":
return handleInitialFailure(currentTime);
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;
}
}
return handleInitialFailure(currentTime);
}
);
const finalFailResult = await handleInitialFailure(currentTime);
memoryCache = { data: finalFailResult, timestamp: Date.now() };
return finalFailResult;
})();
getEnterpriseLicensePromise
.finally(() => {
getEnterpriseLicensePromise = null;
})
.catch(() => {});
return getEnterpriseLicensePromise;
});
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
try {

View File

@@ -15,6 +15,7 @@ type TEnterpriseLicense = {
lastChecked: Date;
isPendingDowngrade: boolean;
fallbackLevel: string;
status: "active" | "expired" | "unreachable" | "no-license";
};
export const ZEnvironmentAuth = z.object({

View File

@@ -12,6 +12,7 @@ interface PendingDowngradeBannerProps {
isPendingDowngrade: boolean;
environmentId: string;
locale: TUserLocale;
status: "active" | "expired" | "unreachable" | "no-license";
}
export const PendingDowngradeBanner = ({
@@ -20,11 +21,12 @@ export const PendingDowngradeBanner = ({
isPendingDowngrade,
environmentId,
locale,
status,
}: PendingDowngradeBannerProps) => {
const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000;
const { t } = useTranslation();
const isLastCheckedWithin72Hours = lastChecked
? new Date().getTime() - lastChecked.getTime() < threeDaysInMillis
? Date.now() - lastChecked.getTime() < threeDaysInMillis
: false;
const scheduledDowngradeDate = new Date(lastChecked.getTime() + threeDaysInMillis);
@@ -36,7 +38,34 @@ export const PendingDowngradeBanner = ({
const [show, setShow] = useState(true);
if (show && active && isPendingDowngrade) {
const isExpired = status === "expired";
const getDescription = () => {
if (isExpired) {
const expiredMessage = t("common.your_license_has_expired_please_renew");
const downgradedMessage = t("common.you_are_downgraded_to_the_community_edition");
return `${expiredMessage} ${downgradedMessage}`;
}
const unreachableMessage = t(
"common.we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable"
);
if (!active) {
return `${unreachableMessage} ${t("common.you_are_downgraded_to_the_community_edition")}`;
}
if (isLastCheckedWithin72Hours) {
const scheduledMessage = t("common.you_will_be_downgraded_to_the_community_edition_on_date", {
date: formattedDate,
});
return `${unreachableMessage} ${scheduledMessage}`;
}
return `${unreachableMessage} ${t("common.you_are_downgraded_to_the_community_edition")}`;
};
if (show && (isPendingDowngrade || isExpired)) {
return (
<div
aria-live="assertive"
@@ -50,17 +79,10 @@ export const PendingDowngradeBanner = ({
<TriangleAlertIcon className="text-error h-6 w-6" aria-hidden="true" />
</div>
<div className="ml-3 w-0 flex-1">
<p className="text-base font-medium text-slate-900">{t("common.pending_downgrade")}</p>
<p className="mt-1 text-sm text-slate-500">
{t(
"common.we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable"
)}{" "}
{isLastCheckedWithin72Hours
? t("common.you_will_be_downgraded_to_the_community_edition_on_date", {
date: formattedDate,
})
: t("common.you_are_downgraded_to_the_community_edition")}
<p className="text-base font-medium text-slate-900">
{isExpired ? t("common.license_expired") : t("common.pending_downgrade")}
</p>
<p className="mt-1 text-sm text-slate-500">{getDescription()}</p>
<Link href={`/environments/${environmentId}/settings/enterprise`}>
<span className="text-sm text-slate-900">{t("common.learn_more")}</span>

View File

@@ -225,10 +225,10 @@ export const PreviewSurvey = ({
)}>
{previewMode === "mobile" && (
<>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
<p className="absolute top-0 left-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
Preview
</p>
<div className="absolute right-0 top-0 m-2">
<div className="absolute top-0 right-0 m-2">
<ResetProgressButton onClick={resetProgress} />
</div>
<MediaBackground
@@ -265,7 +265,7 @@ export const PreviewSurvey = ({
</Modal>
) : (
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute left-5 top-5">
<div className="absolute top-5 left-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}
@@ -373,7 +373,7 @@ export const PreviewSurvey = ({
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isEditorView>
<div className="absolute left-5 top-5">
<div className="absolute top-5 left-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}

View File

@@ -14,42 +14,6 @@ const getHostname = (url) => {
return urlObj.hostname;
};
/**
* Checks if a hostname is a private/loopback address
* Used to conditionally enable dangerouslyAllowLocalIP for self-hosted instances
*/
const isPrivateOrLocalhost = (hostname) => {
if (!hostname) return false;
// Check for localhost variants
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return true;
}
// Check for private IP ranges (RFC 1918)
// 10.0.0.0 - 10.255.255.255
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
return true;
}
// 172.16.0.0 - 172.31.255.255
if (/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
return true;
}
// 192.168.0.0 - 192.168.255.255
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
return true;
}
return false;
};
// Determine if we're running in a private/localhost environment
const webappUrl = process.env.WEBAPP_URL || "http://localhost:3000";
const webappHostname = getHostname(webappUrl);
const shouldAllowLocalIP = isPrivateOrLocalhost(webappHostname);
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
basePath: process.env.BASE_PATH || undefined,
@@ -70,9 +34,6 @@ const nextConfig = {
formats: ["image/webp"], // WebP is faster to process and smaller than JPEG/PNG
minimumCacheTTL: 60, // Cache optimized images for at least 60 seconds
dangerouslyAllowSVG: true, // Allow SVG images
// Next.js 16+ blocks image optimization for URLs resolving to private IPs (localhost, 192.168.x.x, etc.)
// Only enable for self-hosted instances where WEBAPP_URL points to localhost or private IPs
...(shouldAllowLocalIP && { dangerouslyAllowLocalIP: true }),
remotePatterns: [
{
protocol: "https",
@@ -86,15 +47,10 @@ const nextConfig = {
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
// Only allow HTTP localhost in local/private environments
...(shouldAllowLocalIP
? [
{
protocol: "http",
hostname: "localhost",
},
]
: []),
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "app.formbricks.com",

View File

@@ -97,11 +97,11 @@
"jiti": "2.4.2",
"jsonwebtoken": "9.0.2",
"lexical": "0.36.2",
"lodash": "4.17.23",
"lodash": "4.17.21",
"lucide-react": "0.507.0",
"markdown-it": "14.1.0",
"mime-types": "3.0.1",
"next": "16.1.6",
"next": "16.1.3",
"next-auth": "4.24.12",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",
@@ -158,7 +158,7 @@
"autoprefixer": "10.4.21",
"cross-env": "10.0.0",
"dotenv": "16.5.0",
"esbuild": "0.25.12",
"esbuild": "0.25.11",
"postcss": "8.5.3",
"resize-observer-polyfill": "1.5.1",
"ts-node": "10.9.2",

View File

@@ -47,13 +47,8 @@ run_with_timeout() {
}
# Check if migrations should be skipped (e.g., when using Helm migration job)
if [ "${SKIP_STARTUP_MIGRATION:-false}" = "true" ]; then
echo "⏭️ Skipping startup migrations (handled by migration job)"
else
echo "🗃️ Running database migrations..."
run_with_timeout 300 "database migration" node packages/database/dist/scripts/apply-migrations.js
fi
echo "🗃️ Running database migrations..."
run_with_timeout 300 "database migration" node packages/database/dist/scripts/apply-migrations.js
echo "🗃️ Running SAML database setup..."
run_with_timeout 60 "SAML database setup" node packages/database/dist/scripts/create-saml-database.js

View File

@@ -1,95 +0,0 @@
{{- if .Values.migration.enabled }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "formbricks.name" . }}-migration
labels:
{{- include "formbricks.labels" . | nindent 4 }}
annotations:
# ArgoCD sync hooks
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
argocd.argoproj.io/sync-wave: "-1"
{{- if .Values.migration.annotations }}
{{- toYaml .Values.migration.annotations | nindent 4 }}
{{- end }}
spec:
ttlSecondsAfterFinished: {{ .Values.migration.ttlSecondsAfterFinished | default 300 }}
backoffLimit: {{ .Values.migration.backoffLimit | default 3 }}
template:
metadata:
labels:
{{- include "formbricks.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: migration
spec:
restartPolicy: Never
{{- if .Values.deployment.nodeSelector }}
nodeSelector:
{{- toYaml .Values.deployment.nodeSelector | nindent 8 }}
{{- end }}
{{- if .Values.deployment.tolerations }}
tolerations:
{{- toYaml .Values.deployment.tolerations | nindent 8 }}
{{- end }}
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
{{- if .Values.rbac.serviceAccount.enabled }}
serviceAccountName: {{ .Values.rbac.serviceAccount.name | default (include "formbricks.name" .) }}
{{- end }}
{{- if .Values.deployment.securityContext }}
securityContext:
{{- toYaml .Values.deployment.securityContext | nindent 8 }}
{{- end }}
containers:
- name: migration
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
command:
- node
- packages/database/dist/scripts/apply-migrations.js
{{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
envFrom:
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
- secretRef:
name: {{ template "formbricks.name" . }}-app-secrets
{{- end }}
{{- range $value := .Values.deployment.envFrom }}
{{- if (eq .type "configmap") }}
- configMapRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- if (eq .type "secret") }}
- secretRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
env:
{{- range $key, $value := .Values.deployment.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
{{- else }}
{{- toYaml $value | nindent 14 }}
{{- end }}
{{- end }}
{{- if .Values.migration.resources }}
resources:
{{- toYaml .Values.migration.resources | nindent 12 }}
{{- end }}
{{- end }}

View File

@@ -127,10 +127,6 @@ spec:
{{- end }}
{{- end }}
env:
{{- if .Values.migration.enabled }}
- name: SKIP_STARTUP_MIGRATION
value: "true"
{{- end }}
{{- range $key, $value := .Values.deployment.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}

View File

@@ -1,7 +1,7 @@
{{- if (.Values.externalSecret).enabled }}
{{- range $nameSuffix, $data := .Values.externalSecret.files }}
---
apiVersion: external-secrets.io/v1
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ template "formbricks.name" $ }}-{{ $nameSuffix }}

View File

@@ -28,32 +28,6 @@ enterprise:
enabled: false
licenseKey: ""
##########################################################
# Database Migration Job Configuration Helm
##########################################################
migration:
# Enable migration job for ArgoCD deployments
# When enabled, migrations run as a PreSync hook before the deployment
# and the startup migration in the container is skipped
enabled: true
# Additional annotations for the migration job
annotations: {}
# Time to keep the job after completion (seconds)
ttlSecondsAfterFinished: 300
# Number of retries before marking the job as failed
backoffLimit: 3
# Resource requests and limits for the migration job
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
cpu: "100m"
##########################################################
# Deployment Configuration
##########################################################

View File

@@ -46,7 +46,7 @@
"dependencies": {
"react": "19.2.3",
"react-dom": "19.2.3",
"next": "16.1.6"
"next": "16.1.3"
},
"devDependencies": {
"@azure/identity": "4.13.0",
@@ -89,14 +89,13 @@
"node-forge": ">=1.3.2",
"tar-fs": "2.1.4",
"typeorm": ">=0.3.26",
"systeminformation": "5.27.14",
"qs": ">=6.14.1"
"systeminformation": "5.27.14"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update | qs (Dependabot #245) - awaiting googleapis-common and stripe updates"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update"
},
"patchedDependencies": {
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
}
}
}
}

View File

@@ -136,7 +136,7 @@ function NPS({
setHoveredValue(null);
}}>
{colorCoding ? (
<div className={cn("absolute left-0 top-0 h-[6px] w-full", getNPSOptionColor(number))} />
<div className={cn("absolute top-0 left-0 h-[6px] w-full", getNPSOptionColor(number))} />
) : null}
<input
type="radio"

View File

@@ -260,7 +260,7 @@ function Rating({
}}>
{colorCoding ? (
<div
className={cn("absolute left-0 top-0 h-[6px] w-full", getRatingNumberOptionColor(range, number))}
className={cn("absolute top-0 left-0 h-[6px] w-full", getRatingNumberOptionColor(range, number))}
/>
) : null}
<input

View File

@@ -744,7 +744,7 @@ export function Survey({
return (
<>
{localSurvey.type !== "link" ? (
<div className="bg-survey-bg flex h-6 justify-end pr-2 pt-2">
<div className="bg-survey-bg flex h-6 justify-end pt-2 pr-2">
<SurveyCloseButton onClose={onClose} />
</div>
) : null}

460
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff