diff --git a/src/services/login-service.js b/src/services/login-service.js index 83c157b18d..028759a0be 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -27,7 +27,11 @@ define(function (require, exports, module) { require("./setup-login-service"); // this adds loginService to KernalModeTrust require("./promotions"); + const Metrics = require("utils/Metrics"); + const LoginUtils = require("./login-utils"); + const MS_IN_DAY = 10 * 24 * 60 * 60 * 1000; + const TEN_MINUTES = 10 * 60 * 1000; const KernalModeTrust = window.KernalModeTrust; if(!KernalModeTrust){ @@ -43,6 +47,25 @@ define(function (require, exports, module) { // Cached entitlements data let cachedEntitlements = null; + // Last recorded state for entitlements monitoring + let lastRecordedState = null; + + // Debounced trigger for entitlements changed + let entitlementsChangedTimer = null; + + function _debounceEntitlementsChanged() { + if (entitlementsChangedTimer) { + // already scheduled, skip + return; + } + + entitlementsChangedTimer = setTimeout(() => { + LoginService.trigger(EVENT_ENTITLEMENTS_CHANGED); + entitlementsChangedTimer = null; + }, 1000); // atmost 1 entitlement changed event will be triggered in a second + } + + /** * Get entitlements from API or cache * Returns null if user is not logged in @@ -96,7 +119,7 @@ define(function (require, exports, module) { // Trigger event if entitlements changed if (entitlementsChanged) { - LoginService.trigger(EVENT_ENTITLEMENTS_CHANGED, result); + _debounceEntitlementsChanged(); } return cachedEntitlements; @@ -116,12 +139,43 @@ define(function (require, exports, module) { function clearEntitlements() { if (cachedEntitlements) { cachedEntitlements = null; + _debounceEntitlementsChanged(); + } + } - // Trigger event when entitlements are cleared - if (LoginService.trigger) { - LoginService.trigger(EVENT_ENTITLEMENTS_CHANGED, null); + + /** + * Start the 10-minute interval timer for monitoring entitlements + */ + function startEntitlementsMonitor() { + setInterval(async () => { + try { + const current = await getEffectiveEntitlements(false); // Get effective entitlements + + // Check if we need to refresh + const expiredPlanName = LoginUtils.validTillExpired(current, lastRecordedState); + const hasChanged = LoginUtils.haveEntitlementsChanged(current, lastRecordedState); + + if (expiredPlanName || hasChanged) { + console.log(`Entitlements monitor detected changes, Expired: ${expiredPlanName},` + + `changed: ${hasChanged} refreshing...`); + Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "entRefresh", + expiredPlanName ? "exp_"+expiredPlanName : "changed"); + await getEffectiveEntitlements(true); // Force refresh + // if not logged in, the getEffectiveEntitlements will not trigger change even if some trial + // entitlements changed. so we trigger a change anyway here. The debounce will take care of + // multi fire and we are ok with multi fire 1 second apart. + _debounceEntitlementsChanged(); + } + + // Update last recorded state + lastRecordedState = current; + } catch (error) { + console.error('Entitlements monitor error:', error); } - } + }, TEN_MINUTES); + + console.log('Entitlements monitor started (10-minute interval)'); } /** @@ -197,7 +251,8 @@ define(function (require, exports, module) { * @example * // Listen for entitlements changes * const LoginService = window.KernelModeTrust.loginService; - * LoginService.on(LoginService.EVENT_ENTITLEMENTS_CHANGED, (entitlements) => { + * LoginService.on(LoginService.EVENT_ENTITLEMENTS_CHANGED, async() => { + * const entitlements = await LoginService.getEffectiveEntitlements(); * console.log('Entitlements changed:', entitlements); * // Update UI based on new entitlements * }); @@ -229,38 +284,20 @@ define(function (require, exports, module) { if (serverEntitlements.plan.paidSubscriber) { // Already a paid subscriber, return as-is return serverEntitlements; - } else { - // Enhance entitlements for trial user - return { - ...serverEntitlements, - plan: { - ...serverEntitlements.plan, - paidSubscriber: true, - name: brackets.config.main_pro_plan - }, - isInProTrial: true, - trialDaysRemaining: trialDaysRemaining, - entitlements: { - ...serverEntitlements.entitlements, - liveEdit: { - activated: true, - subscribeURL: brackets.config.purchase_url, - upgradeToPlan: brackets.config.main_pro_plan, - validTill: Date.now() + trialDaysRemaining * MS_IN_DAY - } - } - }; } - } else { - // Non-logged-in user with trial - return synthetic entitlements + // Enhance entitlements for trial user return { + ...serverEntitlements, plan: { + ...serverEntitlements.plan, paidSubscriber: true, - name: brackets.config.main_pro_plan + name: brackets.config.main_pro_plan, + validTill: Date.now() + trialDaysRemaining * MS_IN_DAY }, isInProTrial: true, trialDaysRemaining: trialDaysRemaining, entitlements: { + ...serverEntitlements.entitlements, liveEdit: { activated: true, subscribeURL: brackets.config.purchase_url, @@ -270,6 +307,25 @@ define(function (require, exports, module) { } }; } + + // Non-logged-in user with trial - return synthetic entitlements + return { + plan: { + paidSubscriber: true, + name: brackets.config.main_pro_plan, + validTill: Date.now() + trialDaysRemaining * MS_IN_DAY + }, + isInProTrial: true, + trialDaysRemaining: trialDaysRemaining, + entitlements: { + liveEdit: { + activated: true, + subscribeURL: brackets.config.purchase_url, + upgradeToPlan: brackets.config.main_pro_plan, + validTill: Date.now() + trialDaysRemaining * MS_IN_DAY + } + } + }; } // Add functions to secure exports @@ -277,4 +333,7 @@ define(function (require, exports, module) { LoginService.getEffectiveEntitlements = getEffectiveEntitlements; LoginService.clearEntitlements = clearEntitlements; LoginService.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; + + // Start the entitlements monitor timer + startEntitlementsMonitor(); }); diff --git a/src/services/login-utils.js b/src/services/login-utils.js new file mode 100644 index 0000000000..6c306cc807 --- /dev/null +++ b/src/services/login-utils.js @@ -0,0 +1,135 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Login Service Utilities + * + * This module contains utility functions for login service operations, + * including entitlements expiration checking and change detection. + */ + +define(function (require, exports, module) { + + /** + * Check if any validTill time has expired + * + * @param {Object|null} entitlements - Current entitlements object + * @param {Object|null} lastRecordedEntitlement - Previously recorded entitlements + * @returns {string|null} - Name of expired plan/entitlement or null if none expired + */ + function validTillExpired(entitlements, lastRecordedEntitlement) { + if (!entitlements) { + return null; + } + + const now = Date.now(); + + function isNewlyExpired(validTill, lastValidTill) { + return ( + validTill && + validTill < now && // expired now + (!lastValidTill || lastValidTill >= now) // but wasn't expired before + ); + } + + // Check plan validTill + if (entitlements.plan) { + const validTill = entitlements.plan.validTill; + const lastValidTill = (lastRecordedEntitlement && lastRecordedEntitlement.plan) + ? lastRecordedEntitlement.plan.validTill + : null; + + if (isNewlyExpired(validTill, lastValidTill)) { + return entitlements.plan.name || brackets.config.main_pro_plan; + } + } + + // Check entitlements validTill + if (entitlements.entitlements) { + for (const key in entitlements.entitlements) { + const entitlement = entitlements.entitlements[key]; + if (!entitlement) { + continue; + } + + const validTill = entitlement.validTill; + const lastValidTill = (lastRecordedEntitlement && + lastRecordedEntitlement.entitlements && + lastRecordedEntitlement.entitlements[key]) + ? lastRecordedEntitlement.entitlements[key].validTill + : null; + + if (isNewlyExpired(validTill, lastValidTill)) { + return key; + } + } + } + + return null; + } + + /** + * Check if entitlements have changed from last recorded state + * + * @param {Object|null} current - Current entitlements object + * @param {Object|null} last - Last recorded entitlements object + * @returns {boolean} - True if entitlements have changed, false otherwise + */ + function haveEntitlementsChanged(current, last) { + if (!last && !current) { + return false; + } + if ((!last && current) || (!current && last)) { + return true; + } + if ((!last.entitlements && current.entitlements) || (!current.entitlements && last.entitlements)) { + return true; + } + + // Check paidSubscriber changes + const currentPaidSub = current.plan && current.plan.paidSubscriber; + const lastPaidSub = last.plan && last.plan.paidSubscriber; + if (currentPaidSub !== lastPaidSub) { + return true; + } + + // Check plan name changes + const currentPlanName = current.plan && current.plan.name; + const lastPlanName = last.plan && last.plan.name; + if (currentPlanName !== lastPlanName) { + return true; + } + + // Check entitlement activations + if (current.entitlements && last.entitlements) { + for (const key of Object.keys(current.entitlements)) { + const currentActivated = current.entitlements[key] && current.entitlements[key].activated; + const lastActivated = last.entitlements[key] && last.entitlements[key].activated; + if (currentActivated !== lastActivated) { + return true; + } + } + } + + return false; + } + + // Export functions + exports.validTillExpired = validTillExpired; + exports.haveEntitlementsChanged = haveEntitlementsChanged; +}); \ No newline at end of file diff --git a/src/services/promotions.js b/src/services/promotions.js index 9efcc4735e..97263b8702 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -292,8 +292,8 @@ define(function (require, exports, module) { // Also trigger entitlements changed event since effective entitlements have changed // This allows UI components to update based on the new trial status - const effectiveEntitlements = await LoginService.getEffectiveEntitlements(); - LoginService.trigger(LoginService.EVENT_ENTITLEMENTS_CHANGED, effectiveEntitlements); + await LoginService.getEffectiveEntitlements(); + LoginService.trigger(LoginService.EVENT_ENTITLEMENTS_CHANGED); } function _isAnyDialogsVisible() { diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 8e48d70103..468430c71e 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -120,6 +120,7 @@ define(function (require, exports, module) { require("spec/spacing-auto-detect-integ-test"); require("spec/LocalizationUtils-test"); require("spec/ScrollTrackHandler-integ-test"); + require("spec/login-utils-test"); // Integrated extension tests require("spec/Extn-InAppNotifications-integ-test"); require("spec/Extn-RemoteFileAdapter-integ-test"); diff --git a/test/spec/login-utils-test.js b/test/spec/login-utils-test.js new file mode 100644 index 0000000000..8d05cd14c1 --- /dev/null +++ b/test/spec/login-utils-test.js @@ -0,0 +1,500 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect, beforeEach*/ + +define(function (require, exports, module) { + + const LoginUtils = require("services/login-utils"); + + describe("unit:Login Utils", function () { + + describe("validTillExpired", function () { + const now = Date.now(); + const futureTime = now + 86400000; // 1 day from now + const pastTime = now - 86400000; // 1 day ago + const recentPastTime = now - 3600000; // 1 hour ago + + beforeEach(function () { + // Mock brackets.config for tests + window.brackets = window.brackets || {}; + window.brackets.config = window.brackets.config || {}; + window.brackets.config.main_pro_plan = "Phoenix Pro"; + }); + + it("should return null for null entitlements", function () { + const result = LoginUtils.validTillExpired(null, null); + expect(result).toBe(null); + }); + + it("should return null for undefined entitlements", function () { + const result = LoginUtils.validTillExpired(undefined, null); + expect(result).toBe(null); + }); + + it("should return null when no validTill times exist", function () { + const entitlements = { + plan: { name: "Phoenix Pro", paidSubscriber: true }, + entitlements: { + liveEdit: { activated: true } + } + }; + const result = LoginUtils.validTillExpired(entitlements, null); + expect(result).toBe(null); + }); + + it("should return null when validTill times are in the future", function () { + const entitlements = { + plan: { name: "Phoenix Pro", paidSubscriber: true, validTill: futureTime }, + entitlements: { + liveEdit: { activated: true, validTill: futureTime } + } + }; + const result = LoginUtils.validTillExpired(entitlements, null); + expect(result).toBe(null); + }); + + it("should return plan name when plan validTill is newly expired", function () { + const entitlements = { + plan: { name: "Phoenix Pro", paidSubscriber: true, validTill: pastTime }, + entitlements: {} + }; + const lastRecorded = { + plan: { name: "Phoenix Pro", paidSubscriber: true, validTill: futureTime }, + entitlements: {} + }; + const result = LoginUtils.validTillExpired(entitlements, lastRecorded); + expect(result).toBe("Phoenix Pro"); + }); + + it("should return default plan name when plan validTill is newly expired and no name", function () { + const entitlements = { + plan: { paidSubscriber: true, validTill: pastTime }, + entitlements: {} + }; + const lastRecorded = { + plan: { paidSubscriber: true, validTill: futureTime }, + entitlements: {} + }; + const result = LoginUtils.validTillExpired(entitlements, lastRecorded); + expect(result).toBe("Phoenix Pro"); + }); + + it("should return null when plan validTill was already expired", function () { + const entitlements = { + plan: { name: "Phoenix Pro", paidSubscriber: true, validTill: pastTime }, + entitlements: {} + }; + const lastRecorded = { + plan: { name: "Phoenix Pro", paidSubscriber: true, validTill: recentPastTime }, + entitlements: {} + }; + const result = LoginUtils.validTillExpired(entitlements, lastRecorded); + expect(result).toBe(null); + }); + + it("should return entitlement key when entitlement validTill is newly expired", function () { + const entitlements = { + plan: { name: "Phoenix Pro", paidSubscriber: true }, + entitlements: { + liveEdit: { activated: true, validTill: pastTime }, + liveEditAI: { activated: true, validTill: futureTime } + } + }; + const lastRecorded = { + plan: { name: "Phoenix Pro", paidSubscriber: true }, + entitlements: { + liveEdit: { activated: true, validTill: futureTime }, + liveEditAI: { activated: true, validTill: futureTime } + } + }; + const result = LoginUtils.validTillExpired(entitlements, lastRecorded); + expect(result).toBe("liveEdit"); + }); + + it("should return null when entitlement validTill was already expired", function () { + const entitlements = { + plan: { name: "Phoenix Pro", paidSubscriber: true }, + entitlements: { + liveEdit: { activated: true, validTill: pastTime } + } + }; + const lastRecorded = { + plan: { name: "Phoenix Pro", paidSubscriber: true }, + entitlements: { + liveEdit: { activated: true, validTill: recentPastTime } + } + }; + const result = LoginUtils.validTillExpired(entitlements, lastRecorded); + expect(result).toBe(null); + }); + + it("should handle missing entitlements in lastRecorded", function () { + const entitlements = { + plan: { name: "Phoenix Pro", paidSubscriber: true, validTill: pastTime }, + entitlements: { + liveEdit: { activated: true, validTill: pastTime } + } + }; + const result = LoginUtils.validTillExpired(entitlements, null); + expect(result).toBe("Phoenix Pro"); + }); + + it("should skip null entitlements in loop", function () { + const entitlements = { + plan: { name: "Phoenix Pro", paidSubscriber: true }, + entitlements: { + liveEdit: null, + liveEditAI: { activated: true, validTill: pastTime } + } + }; + const lastRecorded = { + plan: { name: "Phoenix Pro", paidSubscriber: true }, + entitlements: { + liveEdit: null, + liveEditAI: { activated: true, validTill: futureTime } + } + }; + const result = LoginUtils.validTillExpired(entitlements, lastRecorded); + expect(result).toBe("liveEditAI"); + }); + + it("should test server response shape", function () { + const serverEntitlements = { + isSuccess: true, + lang: "en", + plan: { + name: "Phoenix Pro", + paidSubscriber: true, + validTill: pastTime + }, + profileview: { + quota: { + titleText: "Ai Quota Used", + usageText: "100 / 200 credits", + usedPercent: 20 + }, + htmlMessage: "