diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 2e0e1d7556..7b6f551094 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1668,7 +1668,6 @@ define({ // promos "PROMO_UPGRADE_TITLE": "You’ve been upgraded to {0}", "PROMO_UPGRADE_MESSAGE": "Enjoy full access to all premium features for the next {0} days:", - "PROMO_ENDED_MESSAGE": "Subscribe now to continue using these advanced features:", "PROMO_CARD_1": "Drag & Drop Elements", "PROMO_CARD_1_MESSAGE": "Rearrange sections visually — Phoenix updates the HTML & CSS for you.", "PROMO_CARD_2": "Image Replacement", @@ -1680,6 +1679,10 @@ define({ "PROMO_LEARN_MORE": "Learn More\u2026", "PROMO_GET_APP_UPSELL_BUTTON": "Get {0}", "PROMO_PRO_ENDED_TITLE": "Your {0} Trial has ended", + "PROMO_ENDED_MESSAGE": "Subscribe now to continue using these advanced features:", + "PROMO_PRO_UNLOCK_PRO_TITLE": "Unlock the power of {0}", + "PROMO_PRO_UNLOCK_LIVE_EDIT_TITLE": "Unlock Live Edit with {0}", + "PROMO_PRO_UNLOCK_MESSAGE": "Subscribe now to unlock these advanced features:", "PROMO_PRO_TRIAL_DAYS_LEFT": "Phoenix Pro Trial ({0} days left)", "GET_PHOENIX_PRO": "Get Phoenix Pro", "USER_FREE_PLAN_NAME": "Free Plan" diff --git a/src/services/entitlements.js b/src/services/entitlements.js new file mode 100644 index 0000000000..d7be8eccd7 --- /dev/null +++ b/src/services/entitlements.js @@ -0,0 +1,201 @@ +/* + * 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. + * + */ + +/** + * Entitlements Service + * + * This module provides a dedicated API for managing user entitlements, + * including plan details, trial status, and feature entitlements. + */ + +define(function (require, exports, module) { + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + // integrated extensions will have access to kernal mode, but not external extensions + throw new Error("Login service should have access to KernalModeTrust. Cannot boot without trust ring"); + } + + const EventDispatcher = require("utils/EventDispatcher"), + Strings = require("strings"); + + const MS_IN_DAY = 24 * 60 * 60 * 1000; + const FREE_PLAN_VALIDITY_DAYS = 10000; + + let LoginService; + + // Create secure exports and set up event dispatcher + const Entitlements = {}; + EventDispatcher.makeEventDispatcher(Entitlements); + + // Event constants + const EVENT_ENTITLEMENTS_CHANGED = "entitlements_changed"; + + /** + * Check if user is logged in. Best to check after `EVENT_ENTITLEMENTS_CHANGED`. + * @returns {*} + */ + function isLoggedIn() { + return LoginService.isLoggedIn(); + } + + /** + * Attempts to sign in to the user's account if the user is not already logged in. + * You should listen to `EVENT_ENTITLEMENTS_CHANGED` to know when the login status changes. This function + * returns immediately and does not wait for the login process to complete. + * + * @return {void} Does not return a value. + */ + function loginToAccount() { + if(isLoggedIn()){ + return; + } + KernalModeTrust.loginService.signInToAccount() + .catch(function(err){ + console.error("Error signing in to account", err); + }); + } + + /** + * Get the plan details from entitlements with fallback to free plan defaults. If the user is + * in pro trial(isInProTrial API), then paidSubscriber will always be true as we need to treat user as paid. + * you should use isInProTrial API to check if user is in pro trial if some trial-related logic needs to be done. + * @returns {Promise} Plan details object + */ + async function getPlanDetails() { + const entitlements = await LoginService.getEffectiveEntitlements(); + + if (entitlements && entitlements.plan) { + return entitlements.plan; + } + + // Fallback to free plan defaults + const currentDate = Date.now(); + return { + paidSubscriber: false, + name: Strings.USER_FREE_PLAN_NAME, + validTill: currentDate + (FREE_PLAN_VALIDITY_DAYS * MS_IN_DAY) + }; + } + + /** + * Check if user is in a pro trial. IF the user is in pro trail, then `plan.paidSubscriber` will always be true. + * @returns {Promise} True if user is in pro trial, false otherwise + */ + async function isInProTrial() { + const entitlements = await LoginService.getEffectiveEntitlements(); + return !!(entitlements && entitlements.isInProTrial); + } + + /** + * Get remaining trial days + * @returns {Promise} Number of remaining trial days + */ + async function getTrialRemainingDays() { + const entitlements = await LoginService.getEffectiveEntitlements(); + return entitlements && entitlements.trialDaysRemaining ? entitlements.trialDaysRemaining : 0; + } + + /** + * Get current raw entitlements. Should not be used directly, use individual feature entitlement instead + * like getLiveEditEntitlement. + * @returns {Promise} Raw entitlements object or null + */ + async function getRawEntitlements() { + return await LoginService.getEntitlements(); + } + + /** + * Get live edit is enabled for user, based on his logged in pro-user/trial status. + * + * @returns {Promise} Live edit entitlement object with the following shape: + * @returns {Promise} entitlement + * @returns {Promise} entitlement.activated - If true, enable live edit feature. + * If false, use promotions.showProUpsellDialog + * with UPSELL_TYPE_LIVE_EDIT to show an upgrade dialog if needed. + * @returns {Promise} entitlement.subscribeURL - URL to subscribe/purchase if not activated + * @returns {Promise} entitlement.upgradeToPlan - Plan name that includes live edit entitlement + * @returns {Promise} [entitlement.validTill] - Timestamp when entitlement expires (if from server) + * + * @example + * const liveEditEntitlement = await Entitlements.getLiveEditEntitlement(); + * if (liveEditEntitlement.activated) { + * // Enable live edit feature + * enableLiveEditFeature(); + * } else { + * // Show upgrade dialog when user tries to use live edit + * promotions.showProUpsellDialog(promotions.UPSELL_TYPE_LIVE_EDIT); + * } + */ + async function getLiveEditEntitlement() { + const entitlements = await LoginService.getEffectiveEntitlements(); + + if (entitlements && entitlements.entitlements && entitlements.entitlements.liveEdit) { + return entitlements.entitlements.liveEdit; + } + + // Fallback defaults when live edit entitlement is not available from API + return { + activated: false, + subscribeURL: brackets.config.purchase_url, + upgradeToPlan: brackets.config.main_pro_plan + }; + } + + // Set up KernalModeTrust.Entitlements + KernalModeTrust.Entitlements = Entitlements; + + let inited = false; + function init() { + if(inited){ + return; + } + inited = true; + LoginService = KernalModeTrust.loginService; + // Set up event forwarding from LoginService + LoginService.on(LoginService.EVENT_ENTITLEMENTS_CHANGED, function() { + Entitlements.trigger(EVENT_ENTITLEMENTS_CHANGED); + }); + } + + // Test-only exports for integration testing + if (Phoenix.isTestWindow) { + window._test_entitlements_exports = { + EntitlementsService: Entitlements, + isLoggedIn, + getPlanDetails, + isInProTrial, + getTrialRemainingDays, + getRawEntitlements, + getLiveEditEntitlement, + loginToAccount + }; + } + + exports.init = init; + // no public exports to prevent extension tampering + + // Add functions to secure exports. These can be accessed via `KernalModeTrust.Entitlements.*` + Entitlements.isLoggedIn = isLoggedIn; + Entitlements.loginToAccount = loginToAccount; + Entitlements.getPlanDetails = getPlanDetails; + Entitlements.isInProTrial = isInProTrial; + Entitlements.getTrialRemainingDays = getTrialRemainingDays; + Entitlements.getRawEntitlements = getRawEntitlements; + Entitlements.getLiveEditEntitlement = getLiveEditEntitlement; + Entitlements.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; +}); diff --git a/src/services/login-browser.js b/src/services/login-browser.js index abe11bb340..147e2769e3 100644 --- a/src/services/login-browser.js +++ b/src/services/login-browser.js @@ -43,7 +43,7 @@ */ define(function (require, exports, module) { - require("./login-service"); // after this, loginService will be in KernalModeTrust + const LoginServiceDirectImport = require("./login-service"); // after this, loginService will be in KernalModeTrust const PreferencesManager = require("preferences/PreferencesManager"), Metrics = require("utils/Metrics"), Dialogs = require("widgets/Dialogs"), @@ -389,11 +389,12 @@ define(function (require, exports, module) { } function init() { - ProfileMenu.init(); if(Phoenix.isNativeApp){ console.log("Browser login service is not needed for native app"); return; } + ProfileMenu.init(); + LoginServiceDirectImport.init(); // Always verify login on browser app start _verifyBrowserLogin().catch(console.error); @@ -416,7 +417,9 @@ define(function (require, exports, module) { if (!Phoenix.isNativeApp) { // kernal exports // Add to existing KernalModeTrust.loginService from login-service.js + // isLoggedIn API shouldn't be used outside loginService, please use Entitlements.isLoggedIn API. LoginService.isLoggedIn = isLoggedIn; + // signInToAccount API shouldn't be used outside loginService, please use Entitlements.loginToAccount API. LoginService.signInToAccount = signInToBrowser; LoginService.signOutAccount = signOutBrowser; LoginService.getProfile = getProfile; diff --git a/src/services/login-desktop.js b/src/services/login-desktop.js index cadb7b5649..82d6286688 100644 --- a/src/services/login-desktop.js +++ b/src/services/login-desktop.js @@ -19,7 +19,7 @@ /*global logger*/ define(function (require, exports, module) { - require("./login-service"); // after this, loginService will be in KernalModeTrust + const LoginServiceDirectImport = require("./login-service"); // after this, loginService will be in KernalModeTrust const EventDispatcher = require("utils/EventDispatcher"), PreferencesManager = require("preferences/PreferencesManager"), @@ -400,6 +400,7 @@ define(function (require, exports, module) { return; } ProfileMenu.init(); + LoginServiceDirectImport.init(); _verifyLogin(true).catch(console.error);// todo raise metrics - silent check on init const pref = PreferencesManager.stateManager.definePreference(PREF_USER_PROFILE_VERSION, 'string', '0'); pref.watchExternalChanges(); @@ -412,7 +413,9 @@ define(function (require, exports, module) { // Only set exports for native apps to avoid conflict with browser login if (Phoenix.isNativeApp) { // kernal exports - add to existing KernalModeTrust.loginService from login-service.js + // isLoggedIn API shouldn't be used outside loginService, please use Entitlements.isLoggedIn API. LoginService.isLoggedIn = isLoggedIn; + // signInToAccount API shouldn't be used outside loginService, please use Entitlements.loginToAccount API. LoginService.signInToAccount = signInToAccount; LoginService.signOutAccount = signOutAccount; LoginService.getProfile = getProfile; diff --git a/src/services/login-service.js b/src/services/login-service.js index 75fe439749..d756b8fc01 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -29,6 +29,7 @@ define(function (require, exports, module) { require("./setup-login-service"); // this adds loginService to KernalModeTrust require("./promotions"); require("./login-utils"); + const EntitlementsDirectImport = require("./entitlements"); // this adds Entitlements to KernalModeTrust const Metrics = require("utils/Metrics"), Strings = require("strings"); @@ -570,6 +571,14 @@ define(function (require, exports, module) { LoginService.getSalt = getSalt; LoginService.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; + let inited = false; + function init() { + if(inited){ + return; + } + inited = true; + EntitlementsDirectImport.init(); + } // Test-only exports for integration testing if (Phoenix.isTestWindow) { window._test_login_service_exports = { @@ -586,4 +595,7 @@ define(function (require, exports, module) { // Start the entitlements monitor timer startEntitlementsMonitor(); + + exports.init = init; + // no public exports to prevent extension tampering }); diff --git a/src/services/pro-dialogs.js b/src/services/pro-dialogs.js index 5575be3b1e..67d139aadf 100644 --- a/src/services/pro-dialogs.js +++ b/src/services/pro-dialogs.js @@ -45,7 +45,11 @@ define(function (require, exports, module) { // save a copy of window.fetch so that extensions wont tamper with it. let fetchFn = window.fetch; - function showProUpgradeDialog(trialDays) { + const UPSELL_TYPE_LIVE_EDIT = "live_edit"; + const UPSELL_TYPE_PRO_TRIAL_ENDED = "pro_trial_ended"; + const UPSELL_TYPE_GET_PRO = "get_pro"; + + function showProTrialStartDialog(trialDays) { const title = StringUtils.format(Strings.PROMO_UPGRADE_TITLE, proTitle); const message = StringUtils.format(Strings.PROMO_UPGRADE_MESSAGE, trialDays); const $template = $(Mustache.render(proUpgradeHTML, { @@ -65,12 +69,38 @@ define(function (require, exports, module) { }); } - function _showLocalProEndedDialog() { - const title = StringUtils.format(Strings.PROMO_PRO_ENDED_TITLE, proTitle); - const buttonGetPro = StringUtils.format(Strings.PROMO_GET_APP_UPSELL_BUTTON, proTitlePlain); + function _getUpsellDialogText(upsellType) { + // our pro dialog has 2 flavors. Local which is shipped with the release for showing if user is offline + // and remote which is fetched from the server if we have a remote offer to show. This fn will be called + // by both of these flavors and we need to return the appropriate text for each. + const buttonGetProText = StringUtils.format(Strings.PROMO_GET_APP_UPSELL_BUTTON, proTitlePlain); + switch (upsellType) { + case UPSELL_TYPE_PRO_TRIAL_ENDED: return { + title: StringUtils.format(Strings.PROMO_PRO_ENDED_TITLE, proTitle), + localDialogMessage: Strings.PROMO_ENDED_MESSAGE, // this will be shown in the local dialog + buttonGetProText + }; + case UPSELL_TYPE_LIVE_EDIT: return { + title: StringUtils.format(Strings.PROMO_PRO_UNLOCK_LIVE_EDIT_TITLE, proTitle), + localDialogMessage: Strings.PROMO_PRO_UNLOCK_MESSAGE, + buttonGetProText + }; + case UPSELL_TYPE_GET_PRO: + default: return { + title: StringUtils.format(Strings.PROMO_PRO_UNLOCK_PRO_TITLE, proTitle), + localDialogMessage: Strings.PROMO_PRO_UNLOCK_MESSAGE, + buttonGetProText + }; + } + } + + function _showLocalProEndedDialog(upsellType) { + const dlgText = _getUpsellDialogText(upsellType); + const title = dlgText.title; + const buttonGetPro = dlgText.buttonGetProText; const $template = $(Mustache.render(proUpgradeHTML, { title, Strings, - message: Strings.PROMO_ENDED_MESSAGE, + message: dlgText.localDialogMessage, secondaryButton: Strings.CANCEL, primaryButton: buttonGetPro })); @@ -86,13 +116,14 @@ define(function (require, exports, module) { }); } - function _showRemoteProEndedDialog(currentVersion, promoHtmlURL, upsellPurchaseURL) { - const buttonGetPro = StringUtils.format(Strings.PROMO_GET_APP_UPSELL_BUTTON, proTitlePlain); - const title = StringUtils.format(Strings.PROMO_PRO_ENDED_TITLE, proTitle); + function _showRemoteProEndedDialog(upsellType, currentVersion, promoHtmlURL, upsellPurchaseURL) { + const dlgText = _getUpsellDialogText(upsellType); + const title = dlgText.title; + const buttonGetPro = dlgText.buttonGetProText; const currentTheme = ThemeManager.getCurrentTheme(); const theme = currentTheme && currentTheme.dark ? "dark" : "light"; const promoURL = `${promoHtmlURL}?lang=${ - brackets.getLocale()}&theme=${theme}&version=${currentVersion}`; + brackets.getLocale()}&theme=${theme}&version=${currentVersion}&upsellType=${upsellType}`; const $template = $(Mustache.render(proEndedHTML, {Strings, title, buttonGetPro, promoURL})); Dialogs.showModalDialogUsingTemplate($template).done(function (id) { console.log("Dialog closed with id: " + id); @@ -106,11 +137,11 @@ define(function (require, exports, module) { }); } - async function showProEndedDialog() { + async function showProUpsellDialog(upsellType) { const currentVersion = window.AppConfig.apiVersion; if (!navigator.onLine) { - _showLocalProEndedDialog(); + _showLocalProEndedDialog(upsellType); return; } @@ -118,18 +149,19 @@ define(function (require, exports, module) { const configURL = `${brackets.config.promotions_url}app/config.json`; const response = await fetchFn(configURL); if (!response.ok) { - _showLocalProEndedDialog(); + _showLocalProEndedDialog(upsellType); return; } const config = await response.json(); if (config.upsell_after_trial_url) { - _showRemoteProEndedDialog(currentVersion, config.upsell_after_trial_url, config.upsell_purchase_url); + _showRemoteProEndedDialog(upsellType, currentVersion, + config.upsell_after_trial_url, config.upsell_purchase_url); } else { - _showLocalProEndedDialog(); + _showLocalProEndedDialog(upsellType); } } catch (error) { - _showLocalProEndedDialog(); + _showLocalProEndedDialog(upsellType); } } @@ -141,6 +173,9 @@ define(function (require, exports, module) { }; } - exports.showProUpgradeDialog = showProUpgradeDialog; - exports.showProEndedDialog = showProEndedDialog; + exports.showProTrialStartDialog = showProTrialStartDialog; + exports.showProUpsellDialog = showProUpsellDialog; + exports.UPSELL_TYPE_PRO_TRIAL_ENDED = UPSELL_TYPE_PRO_TRIAL_ENDED; + exports.UPSELL_TYPE_GET_PRO = UPSELL_TYPE_GET_PRO; + exports.UPSELL_TYPE_LIVE_EDIT = UPSELL_TYPE_LIVE_EDIT; }); diff --git a/src/services/profile-menu.js b/src/services/profile-menu.js index 113e9431e6..f4a150cc17 100644 --- a/src/services/profile-menu.js +++ b/src/services/profile-menu.js @@ -598,7 +598,12 @@ define(function (require, exports, module) { } } + let inited = false; function init() { + if (inited) { + return; + } + inited = true; const helpButtonID = "user-profile-button"; $icon = $("") .attr({ diff --git a/src/services/promotions.js b/src/services/promotions.js index 722d23eb26..193536fb18 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -275,7 +275,7 @@ define(function (require, exports, module) { // For corruption, show trial ended dialog and create expired marker // Do not grant any new trial as possible tampering. console.warn("trial data corrupted"); - ProDialogs.showProEndedDialog(); // Show ended dialog for security + ProDialogs.showProUpsellDialog(ProDialogs.UPSELL_TYPE_PRO_TRIAL_ENDED); // Show ended dialog for security // Create expired trial marker to prevent future trial grants await _setTrialData({ @@ -300,7 +300,7 @@ define(function (require, exports, module) { const hasProSubscription = await _hasProSubscription(); if (!hasProSubscription) { console.log("Existing trial expired, showing promo ended dialog"); - ProDialogs.showProEndedDialog(); + ProDialogs.showProUpsellDialog(ProDialogs.UPSELL_TYPE_PRO_TRIAL_ENDED); } else { console.log("Existing trial expired, but user has pro subscription - skipping promo dialog"); } @@ -354,7 +354,7 @@ define(function (require, exports, module) { // Check if user has pro subscription before showing upgrade dialog const hasProSubscription = await _hasProSubscription(); if (!hasProSubscription) { - ProDialogs.showProUpgradeDialog(trialDays); + ProDialogs.showProTrialStartDialog(trialDays); } else { console.log("Pro trial activated, but user has pro subscription - skipping upgrade dialog"); } diff --git a/test/spec/login-browser-integ-test.js b/test/spec/login-browser-integ-test.js index d0992d83f7..80e52adf18 100644 --- a/test/spec/login-browser-integ-test.js +++ b/test/spec/login-browser-integ-test.js @@ -39,6 +39,7 @@ define(function (require, exports, module) { LoginServiceExports, LoginBrowserExports, ProDialogsExports, + EntitlementsExports, originalOpen, originalFetch; @@ -64,7 +65,8 @@ define(function (require, exports, module) { function () { return testWindow._test_login_service_exports && testWindow._test_login_browser_exports && - testWindow._test_pro_dlg_login_exports; + testWindow._test_pro_dlg_login_exports && + testWindow._test_entitlements_exports; }, "Test exports to be available", 5000 @@ -74,6 +76,7 @@ define(function (require, exports, module) { LoginServiceExports = testWindow._test_login_service_exports; LoginBrowserExports = testWindow._test_login_browser_exports; ProDialogsExports = testWindow._test_pro_dlg_login_exports; + EntitlementsExports = testWindow._test_entitlements_exports; // Store original functions for restoration originalOpen = testWindow.open; @@ -87,7 +90,8 @@ define(function (require, exports, module) { "Profile button to be available", 3000 ); - LoginShared.setup(testWindow, LoginServiceExports, setupProUserMock, performFullLoginFlow); + LoginShared.setup(testWindow, LoginServiceExports, setupProUserMock, performFullLoginFlow, + EntitlementsExports); VIEW_TRIAL_DAYS_LEFT = LoginShared.VIEW_TRIAL_DAYS_LEFT; VIEW_PHOENIX_PRO = LoginShared.VIEW_PHOENIX_PRO; VIEW_PHOENIX_FREE = LoginShared.VIEW_PHOENIX_FREE; diff --git a/test/spec/login-desktop-integ-test.js b/test/spec/login-desktop-integ-test.js index 014cdff84b..6a48bc8ffe 100644 --- a/test/spec/login-desktop-integ-test.js +++ b/test/spec/login-desktop-integ-test.js @@ -46,6 +46,7 @@ define(function (require, exports, module) { LoginServiceExports, LoginDesktopExports, ProDialogsExports, + EntitlementsExports, originalOpenURLInDefaultBrowser, originalCopyToClipboard, originalFetch; @@ -71,7 +72,8 @@ define(function (require, exports, module) { await awaitsFor( function () { return testWindow._test_login_service_exports && - testWindow._test_login_desktop_exports; + testWindow._test_login_desktop_exports && + testWindow._test_entitlements_exports; }, "Test exports to be available", 5000 @@ -81,6 +83,7 @@ define(function (require, exports, module) { LoginServiceExports = testWindow._test_login_service_exports; LoginDesktopExports = testWindow._test_login_desktop_exports; ProDialogsExports = testWindow._test_pro_dlg_login_exports; + EntitlementsExports = testWindow._test_entitlements_exports; // Store original functions for restoration originalOpenURLInDefaultBrowser = testWindow.Phoenix.app.openURLInDefaultBrowser; @@ -95,7 +98,8 @@ define(function (require, exports, module) { "Profile button to be available", 3000 ); - LoginShared.setup(testWindow, LoginServiceExports, setupProUserMock, performFullLoginFlow); + LoginShared.setup(testWindow, LoginServiceExports, setupProUserMock, performFullLoginFlow, + EntitlementsExports); VIEW_TRIAL_DAYS_LEFT = LoginShared.VIEW_TRIAL_DAYS_LEFT; VIEW_PHOENIX_PRO = LoginShared.VIEW_PHOENIX_PRO; VIEW_PHOENIX_FREE = LoginShared.VIEW_PHOENIX_FREE; diff --git a/test/spec/login-shared.js b/test/spec/login-shared.js index 291a3acb08..60f2c0c51a 100644 --- a/test/spec/login-shared.js +++ b/test/spec/login-shared.js @@ -2,7 +2,7 @@ /*global expect, it, awaitsFor*/ define(function (require, exports, module) { - let testWindow, LoginServiceExports, setupProUserMock, performFullLoginFlow; + let testWindow, LoginServiceExports, setupProUserMock, performFullLoginFlow, EntitlementsExports; async function setupTrialState(daysRemaining) { const PromotionExports = testWindow._test_promo_login_exports; @@ -169,11 +169,76 @@ define(function (require, exports, module) { verifyProfileIconBlanked(); } - function setup(_testWindow, _LoginServiceExports, _setupProUserMock, _performFullLoginFlow) { + // Entitlements test utility functions + async function verifyPlanEntitlements(expectedPlan, _testDescription) { + const planDetails = await EntitlementsExports.getPlanDetails(); + + if (expectedPlan) { + expect(planDetails).toBeDefined(); + if (expectedPlan.paidSubscriber !== undefined) { + expect(planDetails.paidSubscriber).toBe(expectedPlan.paidSubscriber); + } + if (expectedPlan.name) { + expect(planDetails.name).toBe(expectedPlan.name); + } + if (expectedPlan.validTill !== undefined) { + expect(planDetails.validTill).toBeDefined(); + } + } else { + expect(planDetails).toBeDefined(); // Should always return something (fallback) + } + } + + async function verifyIsInProTrialEntitlement(expected, _testDescription) { + const isInTrial = await EntitlementsExports.isInProTrial(); + expect(isInTrial).toBe(expected); + } + + async function verifyTrialRemainingDaysEntitlement(expected, _testDescription) { + const remainingDays = await EntitlementsExports.getTrialRemainingDays(); + if (typeof expected === 'number') { + expect(remainingDays).toBe(expected); + } else { + expect(remainingDays).toBeGreaterThanOrEqual(0); + } + } + + async function verifyRawEntitlements(expected, _testDescription) { + const rawEntitlements = await EntitlementsExports.getRawEntitlements(); + + if (expected === null) { + expect(rawEntitlements).toBeNull(); + } else if (expected) { + expect(rawEntitlements).toBeDefined(); + if (expected.plan) { + expect(rawEntitlements.plan).toBeDefined(); + } + if (expected.entitlements) { + expect(rawEntitlements.entitlements).toBeDefined(); + } + } + } + + async function verifyLiveEditEntitlement(expected, _testDescription) { + const liveEditEntitlement = await EntitlementsExports.getLiveEditEntitlement(); + + expect(liveEditEntitlement).toBeDefined(); + expect(liveEditEntitlement.activated).toBe(expected.activated); + + if (expected.subscribeURL) { + expect(liveEditEntitlement.subscribeURL).toBe(expected.subscribeURL); + } + if (expected.upgradeToPlan) { + expect(liveEditEntitlement.upgradeToPlan).toBe(expected.upgradeToPlan); + } + } + + function setup(_testWindow, _LoginServiceExports, _setupProUserMock, _performFullLoginFlow, _EntitlementsExports) { testWindow = _testWindow; LoginServiceExports = _LoginServiceExports; setupProUserMock = _setupProUserMock; performFullLoginFlow = _performFullLoginFlow; + EntitlementsExports = _EntitlementsExports; } function setupSharedTests() { @@ -286,10 +351,18 @@ define(function (require, exports, module) { // Verify initial state (no pro branding) await verifyProBranding(false, "no pro branding to start with"); + // Verify entitlements API consistency for logged out state + await verifyIsInProTrialEntitlement(false, "no trial for logged out user with expired trial"); + await verifyTrialRemainingDaysEntitlement(0, "no trial days remaining"); + // Perform login await performFullLoginFlow(); await verifyProBranding(true, "pro branding to appear after pro user login"); + // Verify entitlements API consistency for logged in pro user + await verifyIsInProTrialEntitlement(false, "pro user should not be in trial"); + await verifyPlanEntitlements({ paidSubscriber: true }, "pro user should have paid subscriber plan"); + // Check profile popup shows pro status (not trial) const $profileButton = testWindow.$("#user-profile-button"); $profileButton.trigger('click'); @@ -308,6 +381,10 @@ define(function (require, exports, module) { // 1. No server entitlements (logged out) // 2. Trial is expired (0 days remaining) await verifyProBranding(false, "Pro branding to disappear after logout"); + + // Verify entitlements API consistency after logout + await verifyRawEntitlements(null, "no raw entitlements when logged out"); + await verifyIsInProTrialEntitlement(false, "no trial after logout"); }); it("should show trial branding for user without pro subscription (active trial)", async function () { @@ -320,12 +397,21 @@ define(function (require, exports, module) { // Verify initial state shows pro branding due to trial await verifyProBranding(true, "Trial branding to appear initially"); + // Verify entitlements API consistency for trial user before login + await verifyIsInProTrialEntitlement(true, "user should be in trial initially"); + await verifyTrialRemainingDaysEntitlement(15, "should have 15 trial days remaining"); + await verifyLiveEditEntitlement({ activated: true }, "live edit should be active during trial"); + // Perform login await performFullLoginFlow(); // Verify pro branding remains after login await verifyProBranding(true, "after trial user login"); + // Verify entitlements API consistency for logged in trial user + await verifyIsInProTrialEntitlement(true, "user should still be in trial after login"); + await verifyPlanEntitlements({ paidSubscriber: true }, "trial user should have paidSubscriber true"); + // Check profile popup shows trial status const $profileButton = testWindow.$("#user-profile-button"); $profileButton.trigger('click'); @@ -342,6 +428,10 @@ define(function (require, exports, module) { // Verify pro branding remains after logout (trial continues) await verifyProBranding(true, "Trial branding to remain after logout"); + // Verify entitlements API consistency after logout (trial still active) + await verifyIsInProTrialEntitlement(true, "trial should persist after logout"); + await verifyRawEntitlements(null, "no raw entitlements when logged out"); + // Check profile popup still shows trial status $profileButton.trigger('click'); await popupToAppear(SIGNIN_POPUP); @@ -394,19 +484,32 @@ define(function (require, exports, module) { it("should show free branding for user without pro subscription (expired trial)", async function () { console.log("llgT: Starting desktop trial user test"); - // Setup: No pro subscription + active trial (15 days) + // Setup: No pro subscription + expired trial setupProUserMock(false); await setupExpiredTrial(); // Verify initial state (no pro branding) await verifyProBranding(false, "no pro branding to start with"); + // Verify entitlements API consistency for logged out user with expired trial + await verifyPlanEntitlements({ paidSubscriber: false, name: testWindow.Strings.USER_FREE_PLAN_NAME }, + "free plan for logged out user with expired trial"); + await verifyIsInProTrialEntitlement(false, "no trial for user with expired trial"); + await verifyTrialRemainingDaysEntitlement(0, "no trial days remaining for expired trial"); + await verifyLiveEditEntitlement({ activated: false }, "live edit deactivated for expired trial"); + // Perform login await performFullLoginFlow(); // Verify pro branding remains after login await verifyProBranding(false, "after trial free user login"); + // Verify entitlements API consistency for logged in free user + await verifyPlanEntitlements({ paidSubscriber: false, name: testWindow.Strings.USER_FREE_PLAN_NAME }, + "free plan for logged in user with expired trial"); + await verifyIsInProTrialEntitlement(false, "still no trial after login"); + await verifyLiveEditEntitlement({ activated: false }, "live edit still deactivated after login"); + // Check profile popup shows free plan status const $profileButton = testWindow.$("#user-profile-button"); $profileButton.trigger('click'); @@ -423,6 +526,10 @@ define(function (require, exports, module) { // Verify pro branding remains after logout (trial continues) await verifyProBranding(false, "Trial branding to remain after logout"); + // Verify entitlements API consistency after logout + await verifyRawEntitlements(null, "no raw entitlements when logged out"); + await verifyIsInProTrialEntitlement(false, "no trial after logout"); + // Check profile popup still shows free plan status as trial expired $profileButton.trigger('click'); await popupToAppear(SIGNIN_POPUP); @@ -443,12 +550,31 @@ define(function (require, exports, module) { // Verify initial state (no pro branding due to expired entitlements) await verifyProBranding(false, "no pro branding initially due to expired entitlements"); + // Verify entitlements API consistency for logged out user with no trial + await verifyPlanEntitlements({ paidSubscriber: false, name: testWindow.Strings.USER_FREE_PLAN_NAME }, + "free plan for logged out user with no trial"); + await verifyIsInProTrialEntitlement(false, "no trial for logged out user"); + await verifyTrialRemainingDaysEntitlement(0, "no trial days remaining"); + await verifyRawEntitlements(null, "no raw entitlements when logged out"); + await verifyLiveEditEntitlement({ activated: false }, "live edit deactivated with no trial"); + // Perform login await performFullLoginFlow(); // Verify pro branding remains false after login (expired entitlements filtered to free) await verifyProBranding(false, "no pro branding after login with expired entitlements"); + // Verify entitlements API consistency for logged in user with expired entitlements + await verifyPlanEntitlements({ paidSubscriber: false }, + "expired entitlements filtered to free plan after login"); + await verifyIsInProTrialEntitlement(false, "no trial for user with expired entitlements"); + await verifyTrialRemainingDaysEntitlement(0, "no trial days for expired entitlements user"); + await verifyLiveEditEntitlement({ + activated: false, + subscribeURL: testWindow.brackets.config.purchase_url, + upgradeToPlan: testWindow.brackets.config.main_pro_plan + }, "live edit deactivated with fallback URLs for expired entitlements"); + // Check profile popup shows free plan status const $profileButton = testWindow.$("#user-profile-button"); $profileButton.trigger('click'); @@ -465,6 +591,10 @@ define(function (require, exports, module) { // Verify pro branding remains false after logout await verifyProBranding(false, "no pro branding after logout with expired entitlements"); + // Verify entitlements API consistency after logout + await verifyRawEntitlements(null, "no raw entitlements when logged out"); + await verifyIsInProTrialEntitlement(false, "no trial after logout"); + // Check profile popup (signed out state) $profileButton.trigger('click'); await popupToAppear(SIGNIN_POPUP); @@ -485,12 +615,27 @@ define(function (require, exports, module) { // Verify initial state shows pro branding due to trial (overrides expired entitlements) await verifyProBranding(true, "pro branding initially due to active trial"); + // Verify entitlements API consistency for logged out user with active trial + await verifyPlanEntitlements({ paidSubscriber: true, name: testWindow.brackets.config.main_pro_plan }, + "trial plan for logged out user overrides expired entitlements"); + await verifyIsInProTrialEntitlement(true, "user should be in trial initially"); + await verifyTrialRemainingDaysEntitlement(10, "should have 10 trial days remaining"); + await verifyRawEntitlements(null, "no raw entitlements when logged out"); + await verifyLiveEditEntitlement({ activated: true }, "live edit activated via trial"); + // Perform login await performFullLoginFlow(); // Verify pro branding remains after login (trial overrides expired server entitlements) await verifyProBranding(true, "pro branding after login - trial overrides expired entitlements"); + // Verify entitlements API consistency for logged in user (trial overrides expired server entitlements) + await verifyPlanEntitlements({ paidSubscriber: true }, + "trial overrides expired server entitlements to show paid subscriber"); + await verifyIsInProTrialEntitlement(true, "user should still be in trial after login"); + await verifyTrialRemainingDaysEntitlement(10, "trial days should remain 10 after login"); + await verifyLiveEditEntitlement({ activated: true }, "live edit should be activated via trial override"); + // Check profile popup shows trial status (not expired server entitlements) const $profileButton = testWindow.$("#user-profile-button"); $profileButton.trigger('click'); @@ -507,6 +652,12 @@ define(function (require, exports, module) { // Verify pro branding remains after logout (trial continues) await verifyProBranding(true, "pro branding after logout - trial still active"); + // Verify entitlements API consistency after logout (trial persists) + await verifyIsInProTrialEntitlement(true, "trial should persist after logout"); + await verifyTrialRemainingDaysEntitlement(10, "trial days should persist after logout"); + await verifyRawEntitlements(null, "no raw entitlements when logged out"); + await verifyLiveEditEntitlement({ activated: true }, "live edit still activated via trial after logout"); + // Check profile popup still shows trial status $profileButton.trigger('click'); await popupToAppear(SIGNIN_POPUP); @@ -516,6 +667,49 @@ define(function (require, exports, module) { // Close popup $profileButton.trigger('click'); }); + + it("should test entitlements event forwarding", async function () { + console.log("Entitlements: Testing event forwarding"); + + let entitlementsEventFired = false; + + // Set up event listeners + const entitlementsService = EntitlementsExports.EntitlementsService; + + const entitlementsHandler = () => { + entitlementsEventFired = true; + }; + + entitlementsService.on(entitlementsService.EVENT_ENTITLEMENTS_CHANGED, entitlementsHandler); + + try { + // Setup basic user mock + setupProUserMock(false); + + // Perform full login flow + await performFullLoginFlow(); + expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(true); + + // Wait for events to fire + await awaitsFor(()=> entitlementsEventFired, "Entitlements events to fire"); + + expect(entitlementsEventFired).toBe(true); + + // Perform a full logout flow and see if entitlement changes are detected + entitlementsEventFired = false; + await performFullLogoutFlow(); + expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(false); + verifyProfileIconBlanked(); + + // Wait for events to fire + await awaitsFor(()=> entitlementsEventFired, "Entitlements events to fire"); + + } finally { + // Cleanup event listeners + entitlementsService.off(entitlementsService.EVENT_ENTITLEMENTS_CHANGED, entitlementsHandler); + await cleanupTrialState(); + } + }); } exports.setup = setup;